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 |