OXYGENHUB

A repository's screen opens on a band of its own sky (refs #23) 6e2af63ae616

Its last 30 days as its pages on the hub draw them, from OxygenHub's GET /repos/OWNER/NAME/sky; no band when the hub cannot give it or has nothing to draw.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 3050ee003ee6
When
· 2026-10-28 01:27 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
6e2af63ae6163e6a31dbe3833015c6921cba104bd7505ce3f3607040750241d1
Depends on
144cbf7bf2b5 1c42a70fcba3 1dfa631436b6 57c0824300db 5ae4af48ddd2 62429abe5869 98dbd1220929 d1f635be9442 db7727e5de19 dce8ebd2482a dfbc3e972872 e2df27a81e5d ff65ea456ff1
Depended on by
aa627197ec18 821035db5a1c 740a452b2669 23279b95b5b2
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, 0 tree operations, 7 files

Browse the tree at this change

modified CHANGELOG.md +1 −1
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 -
- **The hub's living backgrounds (#23):** the website's backdrops come to the phone, the same Rust scenes (copied from OxygenHub's crates/backdrop by scripts/sync-backdrop.sh) stepped in rust-core and drawn by the app. Sign-in has the OxygenHub mark, which answers a finger; the dashboard has Your sky, your repositories and their last 30 days of pushes, proposals, issues, releases and runs, each mark a real event (OxygenHub's `GET /user/sky`; a hub without it shows the quiet field), drawing near your finger and naming the repository it rests by, behind cards it shows through. Live runs and jobs pulse; empty and error screens have the quiet field the hub's error pages have; a screen opened slides in a little from the side it leads to, and goes back the way it came. As on the web, drawing slows 3 s after the last touch and stops after 2 minutes, and it holds still with the system's animations off or battery saver on.
5 +
- **The hub's living backgrounds (#23):** the website's backdrops come to the phone, the same Rust scenes (copied from OxygenHub's crates/backdrop by scripts/sync-backdrop.sh) stepped in rust-core and drawn by the app. Sign-in has the OxygenHub mark, which answers a finger; the dashboard has Your sky, your repositories and their last 30 days of pushes, proposals, issues, releases and runs, each mark a real event (OxygenHub's `GET /user/sky`; a hub without it shows the quiet field), drawing near your finger and naming the repository it rests by, behind cards it shows through. Live runs and jobs pulse; empty and error screens have the quiet field the hub's error pages have; a screen opened slides in a little from the side it leads to, and goes back the way it came. A repository's screen opens on a band of its own sky, its last 30 days as its pages on the hub draw them (OxygenHub's `GET /repos/OWNER/NAME/sky`; shown only when the hub gives it). As on the web, drawing slows 3 s after the last touch and stops after 2 minutes, and it holds still with the system's animations off or battery saver on.
6 6
- **Assign issues from the phone (#4):** an issue's Assignees action shows who it is assigned to, each removable, with Assign me and a field for anyone's username; saved as the whole set (triage access on the hub).
7 7
- **Approve, reject and expedite runs (#5):** a run's job that waits for a person shows Approve and Reject (its approval rules on the hub decide who may), and a live run has Expedite, which asks why it is urgent (a maintainer's, within the owner's cap). Needs OxygenHub 0.9.11 (the job's `approval` and the expedite route).
8 8
- **Review a proposal from the phone (#4):** an open proposal's Reviews section takes a review: Approve, Request changes or Comment, with what you think (needed to ask for changes or to comment), for a phone signed in to change things. An approval from the phone is not sealed, as it holds no key of yours: where a repository needs sealed approvals it does not count yet.
…
22 unchanged lines
modified app/src/main/java/io/oxygenhub/android/api/HubClient.kt +5 −0
…
56 unchanged lines
57 57
    /** Your sky's data, the dashboard backdrop's (#23); a hub before it answers 404. */
58 58
    suspend fun userSky(): UserSky
59 59
60 +
    /** One repository's sky, its pages' backdrop (#23); a hub before it answers 404. */
61 +
    suspend fun repoSky(owner: String, name: String): UserSky
62 +
60 63
    /** A repository's proposals: open (with drafts), landed, closed or all. */
61 64
    suspend fun proposals(owner: String, name: String, state: ProposalState = ProposalState.Open): List<Proposal>
62 65
…
260 unchanged lines
323 326
    }
324 327
325 328
    override suspend fun userSky(): UserSky = get(url("user", "sky"), UserSky.serializer())
329 +
330 +
    override suspend fun repoSky(owner: String, name: String): UserSky = get(url("repos", owner, name, "sky"), UserSky.serializer())
326 331
327 332
    override suspend fun userProposals(landable: Boolean): UserProposals =
328 333
        get(url("user", "proposals") { if (landable) addQueryParameter("landable", "true") }, UserProposals.serializer())
…
126 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/backdrop/Scenes.kt +12 −0
1 1
package io.oxygenhub.android.ui.backdrop
2 2
3 +
import io.oxygenhub.android.api.HubApi
3 4
import io.oxygenhub.android.api.UserSky
5 +
import kotlinx.coroutines.CancellationException
4 6
import uniffi.rust_core.SkyEvent
5 7
import uniffi.rust_core.Backdrop as Scene
6 8
…
6 unchanged lines
13 15
/** Your sky, as on the hub's dashboard; the quiet field when there is nothing of yours to draw. */
14 16
fun skyScene(sky: UserSky?, still: Boolean): Scene =
15 17
    if (sky == null || sky.repos.isEmpty()) Scene.adrift(still) else Scene.sky(sky.repos, skyEvents(sky), 0f, 0f, still)
18 +
19 +
/** A repository's sky, or null when the hub cannot give it (one before #23 answers 404). */
20 +
suspend fun repoSkyOrNull(api: HubApi, owner: String, name: String): UserSky? =
21 +
    try {
22 +
        api.repoSky(owner, name)
23 +
    } catch (e: CancellationException) {
24 +
        throw e
25 +
    } catch (_: Exception) {
26 +
        null
27 +
    }
modified app/src/main/java/io/oxygenhub/android/ui/nav/AppNavigation.kt +3 −0
…
42 unchanged lines
43 43
import io.oxygenhub.android.api.HubApi
44 44
import io.oxygenhub.android.auth.Session
45 45
import io.oxygenhub.android.ui.account.AccountScreen
46 +
import io.oxygenhub.android.ui.backdrop.repoSkyOrNull
46 47
import io.oxygenhub.android.ui.code.ChangeScreen
47 48
import io.oxygenhub.android.ui.code.ChangeViewModel
48 49
import io.oxygenhub.android.ui.code.CodeViewModel
…
191 unchanged lines
240 241
                        onNewIssue = if (session.canWrite) { { nav.navigate(NewIssueRoute(owner, name)) } } else null,
241 242
                        onOpenRun = { nav.navigate(RunRoute(owner, name, it)) },
242 243
                        onOpenOwner = { nav.navigate(OwnerRoute(owner)) },
244 +
                        loadSky = { repoSkyOrNull(api, owner, name) },
243 245
                    )
244 246
                }
245 247
            }
…
13 unchanged lines
259 261
                    onNewIssue = if (session.canWrite) { { nav.navigate(NewIssueRoute(r.owner, r.name)) } } else null,
260 262
                    onOpenRun = { nav.navigate(RunRoute(r.owner, r.name, it)) },
261 263
                    onOpenOwner = { nav.navigate(OwnerRoute(r.owner)) },
264 +
                    loadSky = { repoSkyOrNull(api, r.owner, r.name) },
262 265
                )
263 266
            }
264 267
            composable<OwnerRoute> { e ->
…
114 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/repos/RepositoryScreen.kt +21 −8
…
1 unchanged lines
2 2
3 3
import androidx.compose.foundation.layout.Column
4 4
import androidx.compose.foundation.layout.fillMaxSize
5 -
import androidx.compose.material3.PrimaryScrollableTabRow
6 -
import androidx.compose.material3.Tab
7 -
import androidx.compose.material3.Text
8 -
import androidx.compose.material3.IconButton
9 -
import androidx.compose.material3.Icon
5 +
import androidx.compose.foundation.layout.fillMaxWidth
6 +
import androidx.compose.foundation.layout.height
10 7
import androidx.compose.material.icons.Icons
11 8
import androidx.compose.material.icons.outlined.AccountCircle
12 9
import androidx.compose.material.icons.outlined.ContentCopy
10 +
import androidx.compose.material3.Icon
11 +
import androidx.compose.material3.IconButton
12 +
import androidx.compose.material3.PrimaryScrollableTabRow
13 +
import androidx.compose.material3.Tab
14 +
import androidx.compose.material3.Text
13 15
import androidx.compose.runtime.Composable
14 16
import androidx.compose.runtime.getValue
15 17
import androidx.compose.runtime.mutableIntStateOf
16 18
import androidx.compose.runtime.mutableStateOf
19 +
import androidx.compose.runtime.produceState
17 20
import androidx.compose.runtime.saveable.rememberSaveable
18 21
import androidx.compose.runtime.setValue
19 22
import androidx.compose.ui.Modifier
20 23
import androidx.compose.ui.unit.dp
21 24
import io.oxygenhub.android.api.Proposal
25 +
import io.oxygenhub.android.api.UserSky
26 +
import io.oxygenhub.android.ui.backdrop.Backdrop
27 +
import io.oxygenhub.android.ui.backdrop.skyScene
22 28
import io.oxygenhub.android.ui.code.CodeBrowser
23 29
import io.oxygenhub.android.ui.code.CodeViewModel
30 +
import io.oxygenhub.android.ui.common.AppScreen
24 31
import io.oxygenhub.android.ui.flow.RunList
25 32
import io.oxygenhub.android.ui.flow.RunsViewModel
26 -
import io.oxygenhub.android.ui.releases.ReleaseList
27 -
import io.oxygenhub.android.ui.releases.ReleasesViewModel
28 -
import io.oxygenhub.android.ui.common.AppScreen
29 33
import io.oxygenhub.android.ui.issues.IssueList
30 34
import io.oxygenhub.android.ui.issues.IssuesViewModel
31 35
import io.oxygenhub.android.ui.proposals.ProposalList
32 36
import io.oxygenhub.android.ui.proposals.ProposalsViewModel
37 +
import io.oxygenhub.android.ui.releases.ReleaseList
38 +
import io.oxygenhub.android.ui.releases.ReleasesViewModel
33 39
34 40
/** A repository's tabs: its code, issues, proposals and Flow runs (#3, #5). */
35 41
enum class RepositoryTab(val label: String) { Code("Code"), Issues("Issues"), Proposals("Proposals"), Runs("Runs"), Releases("Releases") }
…
14 unchanged lines
50 56
    onOpenRun: (Long) -> Unit,
51 57
    onOpenOwner: (() -> Unit)? = null,
52 58
    initialTab: RepositoryTab = RepositoryTab.Code,
59 +
    /** The repository's sky (#23), or null when the hub cannot give it. */
60 +
    loadSky: (suspend () -> UserSky?)? = null,
53 61
) {
54 62
    var tab by rememberSaveable { mutableIntStateOf(initialTab.ordinal) }
55 63
    var cloning by rememberSaveable { mutableStateOf(false) }
…
8 unchanged lines
64 72
        },
65 73
    ) {
66 74
        Column(Modifier.fillMaxSize()) {
75 +
            val sky by produceState<UserSky?>(null, issues.owner, issues.name) { value = loadSky?.invoke() }
76 +
            // Its last 30 days as its pages on the hub draw them, in a band under its name.
77 +
            sky?.takeIf { it.events.isNotEmpty() }?.let { s ->
78 +
                Backdrop(key = s, make = { still -> skyScene(s, still) }, modifier = Modifier.fillMaxWidth().height(96.dp)) {}
79 +
            }
67 80
            PrimaryScrollableTabRow(selectedTabIndex = tab, edgePadding = 0.dp) {
68 81
                RepositoryTab.entries.forEach { t ->
69 82
                    Tab(selected = tab == t.ordinal, onClick = { tab = t.ordinal }, text = { Text(t.label) })
…
15 unchanged lines
modified app/src/test/java/io/oxygenhub/android/FakeHubApi.kt +2 −0
…
168 unchanged lines
169 169
        io.oxygenhub.android.api.UserSky(now = 10 * 86_400_000L, repos = listOf("andymccall/oxygen"), events = listOf(listOf(0L, 0L, 9 * 86_400_000L, 3L)))
170 170
    }
171 171
    override suspend fun userSky() = userSky.invoke().also { calls += "user sky" }
172 +
    override suspend fun repoSky(owner: String, name: String) =
173 +
        userSky.invoke().copy(repos = listOf("$owner/$name")).also { calls += "sky $owner/$name" }
172 174
    override suspend fun userProposals(landable: Boolean) =
173 175
        userProposals.invoke().also { calls += if (landable) "user proposals, landable" else "user proposals" }
174 176
    override suspend fun proposals(owner: String, name: String, state: ProposalState): List<Proposal> =
…
25 unchanged lines
modified app/src/test/java/io/oxygenhub/android/ui/DashboardViewModelTest.kt +8 −0
…
7 unchanged lines
8 8
import io.oxygenhub.android.api.UserSky
9 9
import io.oxygenhub.android.auth.Session
10 10
import io.oxygenhub.android.ui.backdrop.Frame
11 +
import io.oxygenhub.android.ui.backdrop.repoSkyOrNull
11 12
import io.oxygenhub.android.ui.backdrop.skyEvents
12 13
import io.oxygenhub.android.ui.backdrop.straight
13 14
import io.oxygenhub.android.ui.common.Load
…
52 unchanged lines
66 67
    }
67 68
68 69
    @Test
70 +
    fun aRepositorysSkyIsNothingFromAHubWithoutIt() = kotlinx.coroutines.test.runTest {
71 +
        assertEquals(listOf("andymccall/oxygen"), repoSkyOrNull(api, "andymccall", "oxygen")?.repos)
72 +
        api.userSky = { throw HubException.Http(404, "no such endpoint") }
73 +
        assertNull(repoSkyOrNull(api, "andymccall", "oxygen"))
74 +
    }
75 +
76 +
    @Test
69 77
    fun leavesOutMalformedSkyEvents() {
70 78
        val sky = UserSky(now = 0, repos = listOf("a/b"), events = listOf(listOf(0L, 9L, 0L, 1L), listOf(3L, 0L, 0L, 1L), listOf(0L), listOf(0L, 4L, 5L, 0L)))
71 79
        val events = skyEvents(sky)
…
65 unchanged lines