| 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 |
} |