Account and organisation pages 62429abe5869
Who an owner is, their open boards and the repositories of theirs you
can read; from a repository's top bar, members, and /{owner} or
/orgs/{name} links.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-26 01:49 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
62429abe58699a35cb07b3a1ec4cba3c9d2ac5dca11f541b55cd8f177ddf120b- Depends on
- 144cbf7bf2b5 1c42a70fcba3 31e4f3755627 57c0824300db 5ae4af48ddd2 98dbd1220929 d1f635be9442 db00632a61d6 e2df27a81e5d e833eb0a9e7f ed6c64d23fca fbc95ee8777c
- Depended on by
- 263c6cad5e51 aa627197ec18 821035db5a1c d98e5a147428 6e2af63ae616 dce8ebd2482a 1dfa631436b6 db7727e5de19 8a657957d5a0
- In bookmarks
- main
- In tags
- v0.1.0 v0.1.1 v0.1.2 v0.1.3 v0.1.4 v0.1.5 v0.1.6 v0.1.7 v0.1.8 v0.1.9
- Edits
- 22 atoms, 3 tree operations, 10 files
Browse the tree at this change
added app/src/main/java/io/oxygenhub/android/ui/owners/Owner.kt +174 −0
| 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 | + | } |
added app/src/test/java/io/oxygenhub/android/ui/OwnerViewModelTest.kt +50 −0
| 1 | + | package io.oxygenhub.android.ui |
|
| 2 | + | ||
| 3 | + | import io.oxygenhub.android.FakeHubApi |
|
| 4 | + | import io.oxygenhub.android.MainDispatcherRule |
|
| 5 | + | import io.oxygenhub.android.api.HubException |
|
| 6 | + | import io.oxygenhub.android.api.Organisation |
|
| 7 | + | import io.oxygenhub.android.api.Owner |
|
| 8 | + | import io.oxygenhub.android.api.Repository |
|
| 9 | + | import io.oxygenhub.android.api.User |
|
| 10 | + | import io.oxygenhub.android.ui.common.Load |
|
| 11 | + | import io.oxygenhub.android.ui.owners.OwnerViewModel |
|
| 12 | + | import org.junit.Assert.assertEquals |
|
| 13 | + | import org.junit.Assert.assertTrue |
|
| 14 | + | import org.junit.Rule |
|
| 15 | + | import org.junit.Test |
|
| 16 | + | ||
| 17 | + | class OwnerViewModelTest { |
|
| 18 | + | @get:Rule val main = MainDispatcherRule() |
|
| 19 | + | ||
| 20 | + | private val api = FakeHubApi().apply { |
|
| 21 | + | repositories = { |
|
| 22 | + | listOf( |
|
| 23 | + | Repository("doomsdayone", "tetris", updatedMs = 1), |
|
| 24 | + | Repository("andymccall", "oxygen", updatedMs = 5), |
|
| 25 | + | Repository("doomsdayone", "supermarioland2", updatedMs = 9), |
|
| 26 | + | ) |
|
| 27 | + | } |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | @Test |
|
| 31 | + | fun aNameIsAnAccountOrElseAnOrganisation() { |
|
| 32 | + | api.accounts = mapOf("andymccall" to User("andymccall", displayName = "Andy McCall")) |
|
| 33 | + | api.orgs = mapOf("doomsdayone" to Organisation("doomsdayone", "Doomsday One", members = listOf("andymccall"))) |
|
| 34 | + | ||
| 35 | + | val andy = (OwnerViewModel(api, "andymccall").state.value as Load.Ready).value |
|
| 36 | + | assertEquals("Andy McCall", andy.owner.shownName) |
|
| 37 | + | assertEquals(listOf("oxygen"), andy.repositories.map { it.name }) |
|
| 38 | + | ||
| 39 | + | val org = (OwnerViewModel(api, "doomsdayone").state.value as Load.Ready).value |
|
| 40 | + | assertTrue(org.owner is Owner.Org) |
|
| 41 | + | assertEquals(listOf("andymccall"), (org.owner as Owner.Org).org.members) |
|
| 42 | + | assertEquals(listOf("supermarioland2", "tetris"), org.repositories.map { it.name }) |
|
| 43 | + | assertTrue("asked as an organisation once no account has the name", "org doomsdayone" in api.calls) |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | @Test |
|
| 47 | + | fun neitherIsAFailure() { |
|
| 48 | + | assertTrue(OwnerViewModel(api, "nobody").state.value is Load.Failed) |
|
| 49 | + | } |
|
| 50 | + | } |
modified CHANGELOG.md +1 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Account and organisation pages:** an owner's page shows who they are (avatar, name, when they joined; an organisation's description and members, each opening their own page), their open project boards and the repositories of theirs you can read, newest first. Opened from a repository's top bar, from a member, and from hub links to `/{owner}` or `/orgs/{name}`. From `GET /users/{name}`, else `GET /orgs/{name}`. |
|
| 5 | 6 | - **Off charge, the runner gives a minute's notice (#8, oxygenflow#76/#77):** taken off charge, the phone claims nothing more, finishes a job the hub expects to end within a minute and hands back any other to another runner, its checkpoint saved so the next attempt resumes where it stopped. Leaving Wi-Fi still hands back at once. Pins OxygenFlow's main (checkpoints, `leave_within`). |
|
| 6 | 7 | - **Diffs on a change (#21):** each file a change touched shows its diff in the app: context, removed and added lines with their old and new numbers, tinted, long lines wrapped, gaps between hunks marked. A small change (up to five files, 400 lines) opens every diff; a larger one starts folded and a file's row opens it. The icon on the row still opens the file at the change. From the hub's change API (hub #320); an older hub sends no lines, and the row says so. |
|
| 7 | 8 | - **The phone as an OxygenFlow runner (#8, first part):** Account → OxygenFlow runner → *Offer this phone as a runner* asks the hub for a one-time token (`POST /api/v1/user/runner-token`) and registers the phone as a runner for your own repositories, labelled `android`, offering the WASM tier. The runner is OxygenFlow's own, built into the app through rust-core (OxygenFlow is a subrepo, `oxygenflow/`, and rust-core's oxy-core is the one it pins), running WASM modules in a thread with wasmtime. It runs in a foreground service with a notification, only while the phone is charging on Wi-Fi (an unmetered network); coming off charge or off Wi-Fi drains it (it takes nothing more and finishes its job). Start and Stop on the card. The app is larger: the runner and wasmtime are about 29 MB per architecture. |
|
| … | 7 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/api/HubClient.kt +9 −0
| … | 126 unchanged lines |
||
| 127 | 127 | ||
| 128 | 128 | /** An account's picture (`/avatar/{name}`, public); null when the hub has none this app can draw. */ |
|
| 129 | 129 | suspend fun avatar(name: String): Avatar? |
|
| 130 | + | ||
| 131 | + | /** An account's public profile, and an organisation's (with its members). */ |
|
| 132 | + | suspend fun account(name: String): User |
|
| 133 | + | ||
| 134 | + | suspend fun organisation(name: String): Organisation |
|
| 130 | 135 | } |
|
| 131 | 136 | ||
| 132 | 137 | /** |
|
| … | 88 unchanged lines |
||
| 221 | 226 | sendJson("PATCH", url("repos", owner, name, "issues", number.toString()), buildJsonObject { |
|
| 222 | 227 | put("labels", JsonArray(labels.map { JsonPrimitive(it) })) |
|
| 223 | 228 | }, Issue.serializer()) |
|
| 229 | + | ||
| 230 | + | override suspend fun account(name: String): User = get(url("users", name), User.serializer()) |
|
| 231 | + | ||
| 232 | + | override suspend fun organisation(name: String): Organisation = get(url("orgs", name), Organisation.serializer()) |
|
| 224 | 233 | ||
| 225 | 234 | override suspend fun repository(owner: String, name: String): Repository = get(url("repos", owner, name), Repository.serializer()) |
|
| 226 | 235 | ||
| … | 149 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/api/Models.kt +25 −0
| … | 219 unchanged lines |
||
| 220 | 220 | val landable: List<ProposalBrief> = emptyList(), |
|
| 221 | 221 | ) |
|
| 222 | 222 | ||
| 223 | + | /** An organisation (`GET /orgs/{name}`). */ |
|
| 224 | + | @Serializable |
|
| 225 | + | data class Organisation( |
|
| 226 | + | val name: String, |
|
| 227 | + | @SerialName("display_name") val displayName: String = "", |
|
| 228 | + | val description: String = "", |
|
| 229 | + | val members: List<String> = emptyList(), |
|
| 230 | + | ) |
|
| 231 | + | ||
| 232 | + | /** An account or an organisation, as its page shows it. */ |
|
| 233 | + | sealed interface Owner { |
|
| 234 | + | val name: String |
|
| 235 | + | val shownName: String |
|
| 236 | + | ||
| 237 | + | data class Person(val user: User) : Owner { |
|
| 238 | + | override val name get() = user.username |
|
| 239 | + | override val shownName get() = user.shownName |
|
| 240 | + | } |
|
| 241 | + | ||
| 242 | + | data class Org(val org: Organisation) : Owner { |
|
| 243 | + | override val name get() = org.name |
|
| 244 | + | override val shownName get() = org.displayName.ifBlank { org.name } |
|
| 245 | + | } |
|
| 246 | + | } |
|
| 247 | + | ||
| 223 | 248 | /** A proposal in a list across repositories. */ |
|
| 224 | 249 | @Serializable |
|
| 225 | 250 | data class ProposalBrief( |
|
| … | 199 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/nav/AppNavigation.kt +18 −0
| … | 39 unchanged lines |
||
| 40 | 40 | import io.oxygenhub.android.auth.Session |
|
| 41 | 41 | import io.oxygenhub.android.ui.account.AccountScreen |
|
| 42 | 42 | import io.oxygenhub.android.ui.code.ChangeScreen |
|
| 43 | + | import io.oxygenhub.android.ui.owners.OwnerScreen |
|
| 44 | + | import io.oxygenhub.android.ui.owners.OwnerViewModel |
|
| 43 | 45 | import io.oxygenhub.android.ui.code.ChangeViewModel |
|
| 44 | 46 | import io.oxygenhub.android.ui.code.CodeViewModel |
|
| 45 | 47 | import io.oxygenhub.android.ui.code.FileScreen |
|
| … | 65 unchanged lines |
||
| 111 | 113 | ||
| 112 | 114 | @Serializable data class NewIssueRoute(val owner: String, val name: String) |
|
| 113 | 115 | ||
| 116 | + | /** An account's or organisation's page. */ |
|
| 117 | + | @Serializable data class OwnerRoute(val name: String) |
|
| 118 | + | ||
| 114 | 119 | /** A Flow run, and a job's log (#5). */ |
|
| 115 | 120 | @Serializable data class RunRoute(val owner: String, val name: String, val number: Long) |
|
| 116 | 121 | ||
| … | 87 unchanged lines |
||
| 204 | 209 | onOpenIssue = { nav.navigate(IssueRoute(owner, name, it)) }, |
|
| 205 | 210 | onNewIssue = if (session.canWrite) { { nav.navigate(NewIssueRoute(owner, name)) } } else null, |
|
| 206 | 211 | onOpenRun = { nav.navigate(RunRoute(owner, name, it)) }, |
|
| 212 | + | onOpenOwner = { nav.navigate(OwnerRoute(owner)) }, |
|
| 207 | 213 | ) |
|
| 208 | 214 | } |
|
| 209 | 215 | } |
|
| … | 11 unchanged lines |
||
| 221 | 227 | onOpenIssue = { nav.navigate(IssueRoute(r.owner, r.name, it)) }, |
|
| 222 | 228 | onNewIssue = if (session.canWrite) { { nav.navigate(NewIssueRoute(r.owner, r.name)) } } else null, |
|
| 223 | 229 | onOpenRun = { nav.navigate(RunRoute(r.owner, r.name, it)) }, |
|
| 230 | + | onOpenOwner = { nav.navigate(OwnerRoute(r.owner)) }, |
|
| 231 | + | ) |
|
| 232 | + | } |
|
| 233 | + | composable<OwnerRoute> { e -> |
|
| 234 | + | val r = e.toRoute<OwnerRoute>() |
|
| 235 | + | OwnerScreen( |
|
| 236 | + | rememberViewModel { OwnerViewModel(api, r.name) }, |
|
| 237 | + | api, |
|
| 238 | + | onBack = { nav.popBackStack() }, |
|
| 239 | + | onOpenOwner = { nav.navigate(OwnerRoute(it)) }, |
|
| 240 | + | onOpenRepository = { nav.navigate(IssuesRoute(it.owner, it.name)) }, |
|
| 241 | + | onOpenProject = { nav.navigate(BoardRoute(it.owner, it.number)) }, |
|
| 224 | 242 | ) |
|
| 225 | 243 | } |
|
| 226 | 244 | composable<ProjectsRoute> { |
|
| … | 95 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/nav/Links.kt +3 −1
| … | 4 unchanged lines |
||
| 5 | 5 | /** |
|
| 6 | 6 | * The app's own screen for a page of the hub (Andy, 2026-10-25: whatever |
|
| 7 | 7 | * is tapped in the inbox opens natively): an issue, proposal, run, change, |
|
| 8 | - | * file, history, board or repository. Null for a page the app does not |
|
| 8 | + | * file, history, board, repository, account or organisation. Null for a page the app does not |
|
| 9 | 9 | * show itself, or another site's, which the browser opens. |
|
| 10 | 10 | */ |
|
| 11 | 11 | fun routeFor(url: String, hub: String): Any? { |
|
| … | 4 unchanged lines |
||
| 16 | 16 | if (parts.size >= 3 && parts[0] == "projects") { |
|
| 17 | 17 | return parts[2].toLongOrNull()?.let { BoardRoute(parts[1], it) } |
|
| 18 | 18 | } |
|
| 19 | + | if (parts.size == 2 && parts[0] == "orgs") return OwnerRoute(parts[1]) |
|
| 20 | + | if (parts.size == 1 && parts[0] !in HUB_PAGES) return OwnerRoute(parts[0]) |
|
| 19 | 21 | if (parts.size < 2 || parts[0] in HUB_PAGES) return null |
|
| 20 | 22 | val (owner, name) = parts[0] to parts[1] |
|
| 21 | 23 | val rest = parts.drop(2) |
|
| … | 18 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/repos/RepositoryScreen.kt +14 −1
| … | 4 unchanged lines |
||
| 5 | 5 | import androidx.compose.material3.PrimaryScrollableTabRow |
|
| 6 | 6 | import androidx.compose.material3.Tab |
|
| 7 | 7 | import androidx.compose.material3.Text |
|
| 8 | + | import androidx.compose.material3.IconButton |
|
| 9 | + | import androidx.compose.material3.Icon |
|
| 10 | + | import androidx.compose.material.icons.Icons |
|
| 11 | + | import androidx.compose.material.icons.outlined.AccountCircle |
|
| 8 | 12 | import androidx.compose.runtime.Composable |
|
| 9 | 13 | import androidx.compose.runtime.getValue |
|
| 10 | 14 | import androidx.compose.runtime.mutableIntStateOf |
|
| … | 28 unchanged lines |
||
| 39 | 43 | onOpenIssue: (Long) -> Unit, |
|
| 40 | 44 | onNewIssue: (() -> Unit)?, |
|
| 41 | 45 | onOpenRun: (Long) -> Unit, |
|
| 46 | + | onOpenOwner: (() -> Unit)? = null, |
|
| 42 | 47 | initialTab: RepositoryTab = RepositoryTab.Code, |
|
| 43 | 48 | ) { |
|
| 44 | 49 | var tab by rememberSaveable { mutableIntStateOf(initialTab.ordinal) } |
|
| 45 | - | AppScreen(title = "${issues.owner}/${issues.name}", onBack = onBack) { |
|
| 50 | + | AppScreen( |
|
| 51 | + | title = "${issues.owner}/${issues.name}", |
|
| 52 | + | onBack = onBack, |
|
| 53 | + | actions = { |
|
| 54 | + | onOpenOwner?.let { open -> |
|
| 55 | + | IconButton(onClick = open) { Icon(Icons.Outlined.AccountCircle, contentDescription = "${issues.owner}'s page") } |
|
| 56 | + | } |
|
| 57 | + | }, |
|
| 58 | + | ) { |
|
| 46 | 59 | Column(Modifier.fillMaxSize()) { |
|
| 47 | 60 | PrimaryScrollableTabRow(selectedTabIndex = tab, edgePadding = 0.dp) { |
|
| 48 | 61 | RepositoryTab.entries.forEach { t -> |
|
| … | 12 unchanged lines |
modified app/src/test/java/io/oxygenhub/android/FakeHubApi.kt +7 −0
| 1 | 1 | package io.oxygenhub.android |
|
| 2 | 2 | ||
| 3 | 3 | import io.oxygenhub.android.api.Avatar |
|
| 4 | + | import io.oxygenhub.android.api.HubException |
|
| 4 | 5 | import io.oxygenhub.android.api.Check |
|
| 5 | 6 | import io.oxygenhub.android.api.Proposal |
|
| 6 | 7 | import io.oxygenhub.android.api.ProposalState |
|
| … | 148 unchanged lines |
||
| 155 | 156 | return autoLand.invoke(number, on) |
|
| 156 | 157 | } |
|
| 157 | 158 | override suspend fun avatar(name: String): Avatar? = avatar.invoke(name).also { calls += "avatar $name" } |
|
| 159 | + | var accounts: Map<String, io.oxygenhub.android.api.User> = emptyMap() |
|
| 160 | + | var orgs: Map<String, io.oxygenhub.android.api.Organisation> = emptyMap() |
|
| 161 | + | override suspend fun account(name: String) = |
|
| 162 | + | (accounts[name] ?: throw HubException.Http(404, "no such account")).also { calls += "account $name" } |
|
| 163 | + | override suspend fun organisation(name: String) = |
|
| 164 | + | (orgs[name] ?: throw HubException.Http(404, "no such organisation")).also { calls += "org $name" } |
|
| 158 | 165 | override suspend fun revokeToken() { |
|
| 159 | 166 | calls += "revoke" |
|
| 160 | 167 | revoke.invoke() |
|
| … | 2 unchanged lines |
modified app/src/test/java/io/oxygenhub/android/ui/LinksTest.kt +4 −0
| … | 5 unchanged lines |
||
| 6 | 6 | import io.oxygenhub.android.ui.nav.HistoryRoute |
|
| 7 | 7 | import io.oxygenhub.android.ui.nav.IssueRoute |
|
| 8 | 8 | import io.oxygenhub.android.ui.nav.IssuesRoute |
|
| 9 | + | import io.oxygenhub.android.ui.nav.OwnerRoute |
|
| 9 | 10 | import io.oxygenhub.android.ui.nav.ProposalRoute |
|
| 10 | 11 | import io.oxygenhub.android.ui.nav.RunRoute |
|
| 11 | 12 | import io.oxygenhub.android.ui.nav.routeFor |
|
| … | 15 unchanged lines |
||
| 27 | 28 | assertEquals(HistoryRoute("andymccall", "oxygen", "main"), r("/andymccall/oxygen/changes/main")) |
|
| 28 | 29 | assertEquals(BoardRoute("doomsdayone", 4), r("/projects/doomsdayone/4")) |
|
| 29 | 30 | assertEquals(IssuesRoute("andymccall", "oxygen"), r("/andymccall/oxygen")) |
|
| 31 | + | assertEquals(OwnerRoute("andymccall"), r("/andymccall")) |
|
| 32 | + | assertEquals(OwnerRoute("doomsdayone"), r("/orgs/doomsdayone")) |
|
| 30 | 33 | } |
|
| 31 | 34 | ||
| 32 | 35 | @Test |
|
| 33 | 36 | fun otherPagesAndSitesGoToTheBrowser() { |
|
| 34 | 37 | assertNull(r("/andymccall/oxygenhub/releases/tag/v0.9.4")) |
|
| 35 | 38 | assertNull(r("/admin/runners")) |
|
| 39 | + | assertNull("a hub page, not an owner", r("/explore")) |
|
| 36 | 40 | assertNull(routeFor("https://example.com/andymccall/oxygen/issue/1", hub)) |
|
| 37 | 41 | assertNull(r("/andymccall/oxygen/issue/not-a-number")) |
|
| 38 | 42 | } |
|
| … | 1 unchanged lines |