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