OXYGENHUB

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.