OXYGENHUB
# 62429abe5869 oxygenhub-android / app / src / main / java / io / oxygenhub / android / ui / owners / Owner.kt
8.1 KiB · last changed Account and organisation pages 10 days ago source blame history raw
1
package io.oxygenhub.android.ui.owners
2
3
import androidx.compose.foundation.clickable
4
import androidx.compose.foundation.layout.Arrangement
5
import androidx.compose.foundation.layout.Column
6
import androidx.compose.foundation.layout.ExperimentalLayoutApi
7
import androidx.compose.foundation.layout.FlowRow
8
import androidx.compose.foundation.layout.Row
9
import androidx.compose.foundation.layout.fillMaxSize
10
import androidx.compose.foundation.layout.padding
11
import androidx.compose.foundation.lazy.LazyColumn
12
import androidx.compose.foundation.lazy.items
13
import androidx.compose.material.icons.Icons
14
import androidx.compose.material.icons.outlined.Lock
15
import androidx.compose.material.icons.outlined.ViewKanban
16
import androidx.compose.material3.AssistChip
17
import androidx.compose.material3.HorizontalDivider
18
import androidx.compose.material3.Icon
19
import androidx.compose.material3.ListItem
20
import androidx.compose.material3.MaterialTheme
21
import androidx.compose.material3.Text
22
import androidx.compose.runtime.Composable
23
import androidx.compose.runtime.getValue
24
import androidx.compose.ui.Alignment
25
import androidx.compose.ui.Modifier
26
import androidx.compose.ui.unit.dp
27
import androidx.lifecycle.ViewModel
28
import androidx.lifecycle.compose.collectAsStateWithLifecycle
29
import io.oxygenhub.android.api.HubApi
30
import io.oxygenhub.android.api.HubException
31
import io.oxygenhub.android.api.Owner
32
import io.oxygenhub.android.api.Project
33
import io.oxygenhub.android.api.Repository
34
import io.oxygenhub.android.api.Visibility
35
import io.oxygenhub.android.ui.common.AppScreen
36
import io.oxygenhub.android.ui.common.AvatarImage
37
import io.oxygenhub.android.ui.common.LoadContent
38
import io.oxygenhub.android.ui.common.Load
39
import io.oxygenhub.android.ui.common.load
40
import io.oxygenhub.android.ui.common.rememberAvatar
41
import io.oxygenhub.android.ui.common.relativeTime
42
import kotlinx.coroutines.Job
43
import kotlinx.coroutines.async
44
import kotlinx.coroutines.coroutineScope
45
import kotlinx.coroutines.flow.MutableStateFlow
46
import kotlinx.coroutines.flow.StateFlow
47
import kotlinx.coroutines.flow.asStateFlow
48
49
/** An owner's page: who they are, their repositories (those you can read) and boards. */
50
data class OwnerPage(val owner: Owner, val repositories: List<Repository>, val projects: List<Project>)
51
52
/** An account's or organisation's page; a name is tried as an account, then as an organisation. */
53
class OwnerViewModel(private val api: HubApi, val name: String) : ViewModel() {
54
    private val _state = MutableStateFlow<Load<OwnerPage>>(Load.Loading)
55
    val state: StateFlow<Load<OwnerPage>> = _state.asStateFlow()
56
    private var job: Job? = null
57
58
    init {
59
        refresh()
60
    }
61
62
    fun refresh() {
63
        job = load(_state, job) {
64
            coroutineScope {
65
                val repos = async { api.repositories().filter { it.owner.equals(name, ignoreCase = true) }.sortedByDescending { it.updatedMs } }
66
                val projects = async { runCatching { api.projects(name) }.getOrDefault(emptyList()).filter { !it.closed } }
67
                val owner = try {
68
                    Owner.Person(api.account(name))
69
                } catch (e: HubException.Http) {
70
                    if (e.status != 404) throw e
71
                    Owner.Org(api.organisation(name))
72
                }
73
                OwnerPage(owner, repos.await(), projects.await())
74
            }
75
        }
76
    }
77
}
78
79
@OptIn(ExperimentalLayoutApi::class)
80
@Composable
81
fun OwnerScreen(
82
    vm: OwnerViewModel,
83
    api: HubApi,
84
    onBack: () -> Unit,
85
    onOpenOwner: (String) -> Unit,
86
    onOpenRepository: (Repository) -> Unit,
87
    onOpenProject: (Project) -> Unit,
88
) {
89
    val state by vm.state.collectAsStateWithLifecycle()
90
    AppScreen(title = vm.name, onBack = onBack) {
91
        LoadContent(state, onRefresh = vm::refresh) { page ->
92
            LazyColumn(Modifier.fillMaxSize()) {
93
                item {
94
                    Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
95
                        AvatarImage(rememberAvatar(api, page.owner.name), page.owner.name, size = 64.dp)
96
                        Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
97
                            Text(page.owner.shownName, style = MaterialTheme.typography.titleLarge)
98
                            Text(
99
                                when (val o = page.owner) {
100
                                    is Owner.Person -> listOfNotNull(
101
                                        o.user.username.takeIf { it != o.shownName },
102
                                        "joined ${relativeTime(o.user.createdMs)}".takeIf { o.user.createdMs > 0 },
103
                                    ).joinToString(" · ")
104
                                    is Owner.Org -> "organisation · ${o.org.members.size} ${if (o.org.members.size == 1) "member" else "members"}"
105
                                },
106
                                style = MaterialTheme.typography.bodyMedium,
107
                                color = MaterialTheme.colorScheme.onSurfaceVariant,
108
                            )
109
                        }
110
                    }
111
                    (page.owner as? Owner.Org)?.let { o ->
112
                        Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
113
                            if (o.org.description.isNotBlank()) Text(o.org.description, style = MaterialTheme.typography.bodyMedium)
114
                            FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
115
                                o.org.members.forEach { m ->
116
                                    AssistChip(
117
                                        onClick = { onOpenOwner(m) },
118
                                        label = { Text(m) },
119
                                        leadingIcon = { AvatarImage(rememberAvatar(api, m), m, size = 18.dp) },
120
                                    )
121
                                }
122
                            }
123
                        }
124
                    }
125
                }
126
                if (page.projects.isNotEmpty()) {
127
                    item { Section("Projects") }
128
                    items(page.projects, key = { "p${it.number}" }) { p ->
129
                        ListItem(
130
                            modifier = Modifier.clickable { onOpenProject(p) },
131
                            leadingContent = { Icon(Icons.Outlined.ViewKanban, contentDescription = null) },
132
                            headlineContent = { Text(p.title.ifBlank { "Project ${p.number}" }) },
133
                            supportingContent = if (p.description.isNotBlank()) {
134
                                { Text(p.description, maxLines = 2) }
135
                            } else {
136
                                null
137
                            },
138
                        )
139
                    }
140
                }
141
                item { Section(if (page.repositories.isEmpty()) "No repositories you can read" else "Repositories") }
142
                items(page.repositories, key = { it.slug }) { r ->
143
                    ListItem(
144
                        modifier = Modifier.clickable { onOpenRepository(r) },
145
                        headlineContent = { Text(r.name) },
146
                        supportingContent = {
147
                            Text(
148
                                listOfNotNull(r.description.takeIf { it.isNotBlank() }, "updated ${relativeTime(r.updatedMs)}".takeIf { r.updatedMs > 0 })
149
                                    .joinToString(" · "),
150
                                maxLines = 2,
151
                            )
152
                        },
153
                        trailingContent = if (r.visibility != Visibility.Public) {
154
                            { Icon(Icons.Outlined.Lock, contentDescription = "Not public") }
155
                        } else {
156
                            null
157
                        },
158
                    )
159
                    HorizontalDivider()
160
                }
161
            }
162
        }
163
    }
164
}
165
166
@Composable
167
private fun Section(title: String) {
168
    Text(
169
        title,
170
        modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp),
171
        style = MaterialTheme.typography.titleSmall,
172
        color = MaterialTheme.colorScheme.primary,
173
    )
174
}