Proposals on the dashboard (closes #18) d1f635be9442
Waiting for your review and Your proposals, as the hub's dashboard lists them (GET /api/v1/user/proposals), above the recent repositories; each opens the proposal screen where it can be landed. Fetched beside the rest of the dashboard; a hub without the call shows neither. Tests for both cases; the screenshot hub implements the proposal calls, and the dashboard screenshot shows the two cards.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key fe2594f26c1c
- When
- · 2026-10-25 11:55 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
d1f635be94426a317a8b8de2e128f65d679b981686247eced57531a1c640ba8c- Depends on
- 0381bd8af55e 0a2858c0dad3 144cbf7bf2b5 3f3508bd5f16 57c0824300db 5ae4af48ddd2 db00632a61d6 e2df27a81e5d
- Depended on by
- aa627197ec18 840c75813f86 6e2af63ae616 1dfa631436b6 62429abe5869 dfbc3e972872 98dbd1220929 ff65ea456ff1 4aee5293461d
- 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
- 25 atoms, 0 tree operations, 12 files
Browse the tree at this change
modified CHANGELOG.md +1 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Proposals on the dashboard (#18):** Waiting for your review (open proposals by others in your repositories you have not reviewed) and Your proposals (your open ones and drafts), as the hub's dashboard lists them, each opening the proposal screen where it can be landed. From the hub's `GET /api/v1/user/proposals`; a hub without it shows neither. |
|
| 5 | 6 | - **Land proposals from the phone (#17):** a repository has Issues and Proposals tabs; proposals open (with drafts), landed, closed or all, each saying whether it is ready to land, what stands in the way, or that it will land when its checks pass. A proposal shows the landing card (Land into the target, after a confirmation; land when its checks pass, on or off; the hub's blockers in its own words, and its reason when it refuses), then its checks (failures first, each opening its run), sealed approvals and asked reviewers, its changes and description. Landing needs `repo:write`, which sign-in now asks for: a phone signed in before this reads only, and says so with a button to sign in again rather than failing. |
|
| 6 | 7 | - **A proper launcher icon (#15):** OxygenHub's mark, the hub's two joined rings in its cyan-to-blue gradient, on the app's night navy with a soft glow, as an adaptive icon (any launcher's mask, inside the safe zone) with a monochrome layer for themed icons; the default robot's bitmaps are gone. A rendered preview is in `docs/screenshots/launcher-icon.png`. |
|
| 7 | 8 | - **Your avatar (#13):** the dashboard's greeting and the Account screen show the signed-in account's avatar from the hub's `/avatar/{name}`: an uploaded picture as it is, or the hub's identicon, read from its SVG (hue and cells) and drawn natively, so no SVG library; the initial while there is neither, or the hub cannot be reached. Fetched without the token (it is public), kept for the run, and refreshed with the dashboard. |
|
| … | 3 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/api/HubClient.kt +5 −0
| … | 35 unchanged lines |
||
| 36 | 36 | /** Revokes the token this client signs in with (`DELETE /user/token`). */ |
|
| 37 | 37 | suspend fun revokeToken() |
|
| 38 | 38 | ||
| 39 | + | /** The proposals the caller should act on, across repositories (the hub's dashboard). */ |
|
| 40 | + | suspend fun userProposals(): UserProposals |
|
| 41 | + | ||
| 39 | 42 | /** A repository's proposals: open (with drafts), landed, closed or all. */ |
|
| 40 | 43 | suspend fun proposals(owner: String, name: String, state: ProposalState = ProposalState.Open): List<Proposal> |
|
| 41 | 44 | ||
| … | 38 unchanged lines |
||
| 80 | 83 | ||
| 81 | 84 | override suspend fun inbox(limit: Int): Inbox = |
|
| 82 | 85 | get(url("notifications") { addQueryParameter("limit", limit.coerceIn(1, 200).toString()) }, Inbox.serializer()) |
|
| 86 | + | ||
| 87 | + | override suspend fun userProposals(): UserProposals = get("user/proposals", UserProposals.serializer()) |
|
| 83 | 88 | ||
| 84 | 89 | override suspend fun proposals(owner: String, name: String, state: ProposalState): List<Proposal> = |
|
| 85 | 90 | get(url("repos", owner, name, "proposals") { addQueryParameter("state", state.query) }, ListSerializer(Proposal.serializer())) |
|
| … | 107 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/api/Models.kt +26 −0
| … | 113 unchanged lines |
||
| 114 | 114 | val isDraft: Boolean get() = state == "draft" |
|
| 115 | 115 | } |
|
| 116 | 116 | ||
| 117 | + | /** `GET /api/v1/user/proposals`: the proposals to act on, as the hub's dashboard lists them. */ |
|
| 118 | + | @Serializable |
|
| 119 | + | data class UserProposals( |
|
| 120 | + | /** Open ones by others in the caller's repositories, not yet reviewed by them. */ |
|
| 121 | + | val review: List<ProposalBrief> = emptyList(), |
|
| 122 | + | /** The caller's own open ones and drafts. */ |
|
| 123 | + | val authored: List<ProposalBrief> = emptyList(), |
|
| 124 | + | ) |
|
| 125 | + | ||
| 126 | + | /** A proposal in a list across repositories. */ |
|
| 127 | + | @Serializable |
|
| 128 | + | data class ProposalBrief( |
|
| 129 | + | /** `owner/name`. */ |
|
| 130 | + | val repository: String, |
|
| 131 | + | val number: Long, |
|
| 132 | + | val title: String = "", |
|
| 133 | + | val author: String = "", |
|
| 134 | + | val state: String = "open", |
|
| 135 | + | val conflicted: Boolean = false, |
|
| 136 | + | @SerialName("updated_ms") val updatedMs: Long = 0, |
|
| 137 | + | val url: String = "", |
|
| 138 | + | ) { |
|
| 139 | + | val owner: String get() = repository.substringBefore('/') |
|
| 140 | + | val name: String get() = repository.substringAfter('/') |
|
| 141 | + | } |
|
| 142 | + | ||
| 117 | 143 | /** A change a proposal adds. */ |
|
| 118 | 144 | @Serializable |
|
| 119 | 145 | data class ProposalChange( |
|
| … | 99 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/dashboard/DashboardScreen.kt +59 −5
| 1 | 1 | package io.oxygenhub.android.ui.dashboard |
|
| 2 | 2 | ||
| 3 | 3 | import androidx.compose.foundation.clickable |
|
| 4 | + | import androidx.compose.material.icons.automirrored.outlined.CallMerge |
|
| 5 | + | import androidx.compose.material.icons.outlined.EditNote |
|
| 6 | + | import androidx.compose.ui.text.style.TextOverflow |
|
| 7 | + | import io.oxygenhub.android.api.ProposalBrief |
|
| 8 | + | import io.oxygenhub.android.ui.common.relativeTime |
|
| 9 | + | import io.oxygenhub.android.ui.theme.LocalStatusColors |
|
| 4 | 10 | import androidx.compose.foundation.layout.Arrangement |
|
| 5 | 11 | import androidx.compose.foundation.layout.BoxWithConstraints |
|
| 6 | 12 | import androidx.compose.foundation.layout.Column |
|
| … | 40 unchanged lines |
||
| 47 | 53 | onOpenRepository: (Repository) -> Unit, |
|
| 48 | 54 | onAllRepositories: () -> Unit, |
|
| 49 | 55 | onInbox: () -> Unit, |
|
| 56 | + | onOpenProposal: (ProposalBrief) -> Unit = {}, |
|
| 50 | 57 | ) { |
|
| 51 | 58 | val state by vm.state.collectAsStateWithLifecycle() |
|
| 52 | 59 | AppScreen(title = "Dashboard", subtitle = hub.substringAfter("://")) { |
|
| 53 | 60 | LoadContent(state, onRefresh = { vm.refresh(); onRefreshInbox() }) { d -> |
|
| 54 | - | DashboardContent(d, inbox, onOpenRepository, onAllRepositories, onInbox) |
|
| 61 | + | DashboardContent(d, inbox, onOpenRepository, onAllRepositories, onInbox, onOpenProposal) |
|
| 55 | 62 | } |
|
| 56 | 63 | } |
|
| 57 | 64 | } |
|
| … | 5 unchanged lines |
||
| 63 | 70 | onOpenRepository: (Repository) -> Unit, |
|
| 64 | 71 | onAllRepositories: () -> Unit, |
|
| 65 | 72 | onInbox: () -> Unit, |
|
| 73 | + | onOpenProposal: (ProposalBrief) -> Unit = {}, |
|
| 66 | 74 | ) { |
|
| 67 | 75 | BoxWithConstraints { |
|
| 68 | 76 | // Side by side once there is room (a tablet, or a foldable open). |
|
| … | 11 unchanged lines |
||
| 80 | 88 | Spacer(Modifier.height(16.dp)) |
|
| 81 | 89 | if (wide) { |
|
| 82 | 90 | Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { |
|
| 83 | - | Column(Modifier.weight(2f)) { Recent(d, onOpenRepository, onAllRepositories) } |
|
| 91 | + | Column(Modifier.weight(2f), verticalArrangement = Arrangement.spacedBy(16.dp)) { |
|
| 92 | + | Proposals(d, onOpenProposal) |
|
| 93 | + | Recent(d, onOpenRepository, onAllRepositories) |
|
| 94 | + | } |
|
| 84 | 95 | Column(Modifier.weight(1f)) { Unread(inbox, onInbox) } |
|
| 85 | 96 | } |
|
| 86 | 97 | } else { |
|
| 87 | - | Unread(inbox, onInbox) |
|
| 88 | - | Spacer(Modifier.height(16.dp)) |
|
| 89 | - | Recent(d, onOpenRepository, onAllRepositories) |
|
| 98 | + | Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { |
|
| 99 | + | Unread(inbox, onInbox) |
|
| 100 | + | Proposals(d, onOpenProposal) |
|
| 101 | + | Recent(d, onOpenRepository, onAllRepositories) |
|
| 102 | + | } |
|
| 90 | 103 | } |
|
| 104 | + | } |
|
| 105 | + | } |
|
| 106 | + | } |
|
| 107 | + | ||
| 108 | + | /** Proposals waiting for the account's review, then its own: each opens where it can be landed. */ |
|
| 109 | + | @Composable |
|
| 110 | + | private fun Proposals(d: Dashboard, onOpen: (ProposalBrief) -> Unit) { |
|
| 111 | + | val p = d.proposals ?: return |
|
| 112 | + | Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { |
|
| 113 | + | if (p.review.isNotEmpty()) ProposalCard("Waiting for your review", p.review, onOpen) |
|
| 114 | + | if (p.authored.isNotEmpty()) ProposalCard("Your proposals", p.authored, onOpen) |
|
| 115 | + | } |
|
| 116 | + | } |
|
| 117 | + | ||
| 118 | + | @Composable |
|
| 119 | + | private fun ProposalCard(title: String, list: List<ProposalBrief>, onOpen: (ProposalBrief) -> Unit) { |
|
| 120 | + | Card(Modifier.fillMaxWidth()) { |
|
| 121 | + | Text( |
|
| 122 | + | "$title · ${list.size}", |
|
| 123 | + | style = MaterialTheme.typography.titleMedium, |
|
| 124 | + | modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 4.dp), |
|
| 125 | + | ) |
|
| 126 | + | list.forEachIndexed { i, b -> |
|
| 127 | + | if (i > 0) HorizontalDivider() |
|
| 128 | + | ListItem( |
|
| 129 | + | modifier = Modifier.clickable { onOpen(b) }, |
|
| 130 | + | colors = ListItemDefaults.colors(containerColor = Color.Transparent), |
|
| 131 | + | leadingContent = { |
|
| 132 | + | Icon( |
|
| 133 | + | if (b.state == "draft") Icons.Outlined.EditNote else Icons.AutoMirrored.Outlined.CallMerge, |
|
| 134 | + | contentDescription = b.state, |
|
| 135 | + | tint = if (b.state == "draft") MaterialTheme.colorScheme.onSurfaceVariant else LocalStatusColors.current.ok, |
|
| 136 | + | ) |
|
| 137 | + | }, |
|
| 138 | + | headlineContent = { Text(b.title, maxLines = 2, overflow = TextOverflow.Ellipsis) }, |
|
| 139 | + | supportingContent = { |
|
| 140 | + | val by = if (title.startsWith("Waiting")) " by ${b.author}" else "" |
|
| 141 | + | val conflicts = if (b.conflicted) " · conflicts" else "" |
|
| 142 | + | Text("${b.repository} #${b.number}$by · ${relativeTime(b.updatedMs)}$conflicts", maxLines = 1, overflow = TextOverflow.Ellipsis) |
|
| 143 | + | }, |
|
| 144 | + | ) |
|
| 91 | 145 | } |
|
| 92 | 146 | } |
|
| 93 | 147 | } |
|
| … | 53 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/dashboard/DashboardViewModel.kt +14 −0
| … | 4 unchanged lines |
||
| 5 | 5 | import io.oxygenhub.android.api.HubApi |
|
| 6 | 6 | import io.oxygenhub.android.api.Repository |
|
| 7 | 7 | import io.oxygenhub.android.api.User |
|
| 8 | + | import io.oxygenhub.android.api.UserProposals |
|
| 8 | 9 | import io.oxygenhub.android.auth.Session |
|
| 9 | 10 | import io.oxygenhub.android.ui.common.Avatars |
|
| 10 | 11 | import io.oxygenhub.android.ui.common.Load |
|
| … | 15 unchanged lines |
||
| 26 | 27 | val avatar: Avatar? = null, |
|
| 27 | 28 | /** Whose avatar: the signed-in account's name. */ |
|
| 28 | 29 | val username: String = "", |
|
| 30 | + | /** Proposals to review and the account's own (#18); null when the hub cannot say. */ |
|
| 31 | + | val proposals: UserProposals? = null, |
|
| 29 | 32 | /** The newest-updated repositories: the user's own, or everyone's when they own none. */ |
|
| 30 | 33 | val recent: List<Repository>, |
|
| 31 | 34 | val recentAreYours: Boolean, |
|
| … | 29 unchanged lines |
||
| 61 | 64 | null |
|
| 62 | 65 | } |
|
| 63 | 66 | } |
|
| 67 | + | // As are the proposals: a hub without the call shows none. |
|
| 68 | + | val proposals = async { |
|
| 69 | + | try { |
|
| 70 | + | api.userProposals() |
|
| 71 | + | } catch (e: CancellationException) { |
|
| 72 | + | throw e |
|
| 73 | + | } catch (_: Exception) { |
|
| 74 | + | null |
|
| 75 | + | } |
|
| 76 | + | } |
|
| 64 | 77 | // As is the avatar: its failure leaves the last one, or the initial. |
|
| 65 | 78 | val avatar = async { Avatars.load(api, session.username) } |
|
| 66 | 79 | val repos = api.repositories() |
|
| … | 6 unchanged lines |
||
| 73 | 86 | user = me, |
|
| 74 | 87 | avatar = avatar.await(), |
|
| 75 | 88 | username = username, |
|
| 89 | + | proposals = proposals.await(), |
|
| 76 | 90 | recent = pool.sortedByDescending { it.updatedMs }.take(RECENT), |
|
| 77 | 91 | recentAreYours = mine.isNotEmpty(), |
|
| 78 | 92 | repositoryCount = mine.size, |
|
| … | 16 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/nav/AppNavigation.kt +1 −0
| … | 132 unchanged lines |
||
| 133 | 133 | onOpenRepository = { nav.navigate(IssuesRoute(it.owner, it.name)) }, |
|
| 134 | 134 | onAllRepositories = { nav.goTo(TopLevel.Repositories) }, |
|
| 135 | 135 | onInbox = { nav.goTo(TopLevel.Inbox) }, |
|
| 136 | + | onOpenProposal = { nav.navigate(ProposalRoute(it.owner, it.name, it.number)) }, |
|
| 136 | 137 | ) |
|
| 137 | 138 | } |
|
| 138 | 139 | composable<RepositoriesRoute> { |
|
| … | 44 unchanged lines |
modified app/src/screenshotTest/kotlin/io/oxygenhub/android/Screenshots.kt +20 −0
| … | 21 unchanged lines |
||
| 22 | 22 | import androidx.compose.ui.tooling.preview.Preview |
|
| 23 | 23 | import com.android.tools.screenshot.PreviewTest |
|
| 24 | 24 | import io.oxygenhub.android.api.Avatar |
|
| 25 | + | import io.oxygenhub.android.api.Check |
|
| 26 | + | import io.oxygenhub.android.api.Proposal |
|
| 27 | + | import io.oxygenhub.android.api.ProposalBrief |
|
| 28 | + | import io.oxygenhub.android.api.ProposalState |
|
| 29 | + | import io.oxygenhub.android.api.UserProposals |
|
| 25 | 30 | import io.oxygenhub.android.api.DeviceCode |
|
| 26 | 31 | import io.oxygenhub.android.api.HubApi |
|
| 27 | 32 | import io.oxygenhub.android.api.Inbox |
|
| … | 64 unchanged lines |
||
| 92 | 97 | override suspend fun markAllRead() {} |
|
| 93 | 98 | override suspend fun revokeToken() {} |
|
| 94 | 99 | ||
| 100 | + | override suspend fun userProposals() = UserProposals( |
|
| 101 | + | review = listOf(ProposalBrief("babbage/difference-engine", 12, "Carry the tens with a ratchet", "babbage", updatedMs = now - 2 * hour)), |
|
| 102 | + | authored = listOf(ProposalBrief("ada/engine", 44, "Note G: loop the Bernoulli numbers", "ada", updatedMs = now - 5 * hour)), |
|
| 103 | + | ) |
|
| 104 | + | ||
| 105 | + | override suspend fun proposals(owner: String, name: String, state: ProposalState) = emptyList<Proposal>() |
|
| 106 | + | ||
| 107 | + | override suspend fun proposal(owner: String, name: String, number: Long) = Proposal(number, "Note G") |
|
| 108 | + | ||
| 109 | + | override suspend fun checks(owner: String, name: String, number: Long) = emptyList<Check>() |
|
| 110 | + | ||
| 111 | + | override suspend fun land(owner: String, name: String, number: Long) = proposal(owner, name, number) |
|
| 112 | + | ||
| 113 | + | override suspend fun setAutoLand(owner: String, name: String, number: Long, on: Boolean) = proposal(owner, name, number) |
|
| 114 | + | ||
| 95 | 115 | // Ada's identicon, as the hub would draw it. |
|
| 96 | 116 | override suspend fun avatar(name: String) = |
|
| 97 | 117 | Avatar.Identicon(196, setOf(1 to 1, 5 to 1, 2 to 2, 3 to 2, 4 to 2, 1 to 3, 3 to 3, 5 to 3, 2 to 4, 4 to 4, 1 to 5, 5 to 5)) |
|
| … | 78 unchanged lines |
modified app/src/test/java/io/oxygenhub/android/FakeHubApi.kt +6 −0
| … | 34 unchanged lines |
||
| 35 | 35 | var proposal: (Long) -> Proposal = { n -> Fixtures.proposals.first { it.number == n } } |
|
| 36 | 36 | var checks: (Long) -> List<Check> = { Fixtures.checks } |
|
| 37 | 37 | var land: (Long) -> Proposal = { n -> proposal(n).copy(state = "landed", landed = io.oxygenhub.android.api.Landed("andymccall", "api (token phone)", 1)) } |
|
| 38 | + | var userProposals: () -> io.oxygenhub.android.api.UserProposals = { |
|
| 39 | + | io.oxygenhub.android.api.UserProposals( |
|
| 40 | + | review = listOf(io.oxygenhub.android.api.ProposalBrief("andymccall/oxygenhub", 300, "Show a column's archived cards", "ada")), |
|
| 41 | + | ) |
|
| 42 | + | } |
|
| 38 | 43 | var autoLand: (Long, Boolean) -> Proposal = { n, on -> proposal(n).copy(autoLand = if (on) "andymccall" else null) } |
|
| 39 | 44 | ||
| 40 | 45 | val calls = mutableListOf<String>() |
|
| … | 11 unchanged lines |
||
| 52 | 57 | calls += "read all" |
|
| 53 | 58 | markAllRead.invoke() |
|
| 54 | 59 | } |
|
| 60 | + | override suspend fun userProposals() = userProposals.invoke().also { calls += "user proposals" } |
|
| 55 | 61 | override suspend fun proposals(owner: String, name: String, state: ProposalState): List<Proposal> = |
|
| 56 | 62 | proposals.invoke(state).also { calls += "proposals $owner/$name ${state.query}" } |
|
| 57 | 63 | override suspend fun proposal(owner: String, name: String, number: Long): Proposal = |
|
| … | 17 unchanged lines |
modified app/src/test/java/io/oxygenhub/android/ui/DashboardViewModelTest.kt +11 −0
| … | 46 unchanged lines |
||
| 47 | 47 | } |
|
| 48 | 48 | ||
| 49 | 49 | @Test |
|
| 50 | + | fun showsProposalsToReviewAndNoneFromAHubWithoutTheCall() { |
|
| 51 | + | val vm = DashboardViewModel(api, session) { 9 } |
|
| 52 | + | assertEquals(listOf(300L), ready(vm).proposals?.review?.map { it.number }) |
|
| 53 | + | assertEquals("andymccall" to "oxygenhub", ready(vm).proposals!!.review[0].let { it.owner to it.name }) |
|
| 54 | + | api.userProposals = { throw HubException.Http(404, "no such endpoint") } |
|
| 55 | + | vm.refresh() |
|
| 56 | + | assertNull(ready(vm).proposals) |
|
| 57 | + | assertEquals(2, ready(vm).repositoryCount) |
|
| 58 | + | } |
|
| 59 | + | ||
| 60 | + | @Test |
|
| 50 | 61 | fun withNoRepositoriesOfTheirOwnShowsTheHubs() { |
|
| 51 | 62 | val d = ready(DashboardViewModel(api, session.copy(username = "newcomer")) { 14 }.also { |
|
| 52 | 63 | api.user = { Fixtures.user.copy(username = "newcomer", displayName = "") } |
|
| … | 33 unchanged lines |
replaced docs/screenshots/dashboard-phone.png +0 −0
No line changes.
replaced docs/screenshots/repositories-foldable.png +0 −0
No line changes.
replaced docs/screenshots/repositories-tablet-dark.png +0 −0
No line changes.