A home screen widget: latest runs, unread, reviews waiting (closes #28) b610bc6eeab7
Jetpack Glance 1.1.1: the latest three runs of your repositories with their status, unread notifications and proposals waiting for your review, in OxygenHub's palette; a tap opens the app; refreshed every half hour and whenever the app comes to the front; each part left out on its own when the hub cannot give it. Pins the Android image d5dabda3 (rebuilt with Glance's Gradle dependencies).
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 0ce605831c94
- When
- · 2026-10-28 17:14 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
b610bc6eeab75fd595eb8d02ad317b23f07bfa3adf136c304f7e133358b7261b- Depends on
- 144cbf7bf2b5 206d05a042e7 4c0d52760d2f 57c0824300db 5ae4af48ddd2 821035db5a1c 8a657957d5a0 9481bfa7c5d0 aa21eb5e0e5b d98e5a147428 e0e4a1c48a77 ff65ea456ff1
- Depended on by
- d2e553089d82 fb420bbdb058 943c54cf6a56 aa627197ec18 6823c243eaac
- In bookmarks
- main
- In tags
- 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
- 18 atoms, 4 tree operations, 11 files
Browse the tree at this change
added app/src/main/java/io/oxygenhub/android/widget/HubWidget.kt +135 −0
| 1 | + | package io.oxygenhub.android.widget |
|
| 2 | + | ||
| 3 | + | import android.content.Context |
|
| 4 | + | import androidx.compose.runtime.Composable |
|
| 5 | + | import androidx.compose.ui.graphics.Color |
|
| 6 | + | import androidx.compose.ui.unit.dp |
|
| 7 | + | import androidx.compose.ui.unit.sp |
|
| 8 | + | import androidx.glance.GlanceId |
|
| 9 | + | import androidx.glance.GlanceModifier |
|
| 10 | + | import androidx.glance.GlanceTheme |
|
| 11 | + | import androidx.glance.action.actionStartActivity |
|
| 12 | + | import androidx.glance.action.clickable |
|
| 13 | + | import androidx.glance.appwidget.GlanceAppWidget |
|
| 14 | + | import androidx.glance.appwidget.GlanceAppWidgetReceiver |
|
| 15 | + | import androidx.glance.appwidget.cornerRadius |
|
| 16 | + | import androidx.glance.appwidget.provideContent |
|
| 17 | + | import androidx.glance.background |
|
| 18 | + | import androidx.glance.color.ColorProvider |
|
| 19 | + | import androidx.glance.layout.Alignment |
|
| 20 | + | import androidx.glance.layout.Box |
|
| 21 | + | import androidx.glance.layout.Column |
|
| 22 | + | import androidx.glance.layout.Row |
|
| 23 | + | import androidx.glance.layout.Spacer |
|
| 24 | + | import androidx.glance.layout.fillMaxSize |
|
| 25 | + | import androidx.glance.layout.fillMaxWidth |
|
| 26 | + | import androidx.glance.layout.height |
|
| 27 | + | import androidx.glance.layout.padding |
|
| 28 | + | import androidx.glance.layout.size |
|
| 29 | + | import androidx.glance.layout.width |
|
| 30 | + | import androidx.glance.material3.ColorProviders |
|
| 31 | + | import androidx.glance.text.FontWeight |
|
| 32 | + | import androidx.glance.text.Text |
|
| 33 | + | import androidx.glance.text.TextStyle |
|
| 34 | + | import io.oxygenhub.android.MainActivity |
|
| 35 | + | import io.oxygenhub.android.OxygenHubApp |
|
| 36 | + | import io.oxygenhub.android.api.HubApi |
|
| 37 | + | import io.oxygenhub.android.api.UserFlowRun |
|
| 38 | + | import io.oxygenhub.android.ui.theme.DarkBad |
|
| 39 | + | import io.oxygenhub.android.ui.theme.DarkColorScheme |
|
| 40 | + | import io.oxygenhub.android.ui.theme.DarkOk |
|
| 41 | + | import io.oxygenhub.android.ui.theme.DarkWarn |
|
| 42 | + | import io.oxygenhub.android.ui.theme.LightBad |
|
| 43 | + | import io.oxygenhub.android.ui.theme.LightColorScheme |
|
| 44 | + | import io.oxygenhub.android.ui.theme.LightOk |
|
| 45 | + | import io.oxygenhub.android.ui.theme.LightWarn |
|
| 46 | + | import kotlinx.coroutines.CancellationException |
|
| 47 | + | import kotlinx.coroutines.async |
|
| 48 | + | import kotlinx.coroutines.coroutineScope |
|
| 49 | + | ||
| 50 | + | /** What the widget shows (#28); `signedIn` false asks for a sign-in instead. */ |
|
| 51 | + | data class WidgetData( |
|
| 52 | + | val signedIn: Boolean, |
|
| 53 | + | val runs: List<UserFlowRun> = emptyList(), |
|
| 54 | + | val unread: Long? = null, |
|
| 55 | + | val reviews: Int? = null, |
|
| 56 | + | ) { |
|
| 57 | + | companion object { |
|
| 58 | + | /** Each part on its own: one the hub cannot give is left out, not the whole widget. */ |
|
| 59 | + | suspend fun load(api: HubApi?): WidgetData = coroutineScope { |
|
| 60 | + | if (api == null) return@coroutineScope WidgetData(signedIn = false) |
|
| 61 | + | suspend fun <T> quietly(f: suspend () -> T): T? = try { |
|
| 62 | + | f() |
|
| 63 | + | } catch (e: CancellationException) { |
|
| 64 | + | throw e |
|
| 65 | + | } catch (_: Exception) { |
|
| 66 | + | null |
|
| 67 | + | } |
|
| 68 | + | val runs = async { quietly { api.userFlowRuns() } } |
|
| 69 | + | val unread = async { quietly { api.inbox(limit = 1).unread } } |
|
| 70 | + | val reviews = async { quietly { api.userProposals().review.size } } |
|
| 71 | + | WidgetData(true, runs.await().orEmpty().take(MAX_RUNS), unread.await(), reviews.await()) |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | const val MAX_RUNS = 3 |
|
| 75 | + | } |
|
| 76 | + | } |
|
| 77 | + | ||
| 78 | + | /** A home screen widget (#28): your latest runs, unread notifications and reviews waiting; a tap opens the app. */ |
|
| 79 | + | class HubWidget : GlanceAppWidget() { |
|
| 80 | + | override suspend fun provideGlance(context: Context, id: GlanceId) { |
|
| 81 | + | val api = (context.applicationContext as OxygenHubApp).container.sessions.api() |
|
| 82 | + | val data = WidgetData.load(api) |
|
| 83 | + | provideContent { |
|
| 84 | + | GlanceTheme(colors = ColorProviders(light = LightColorScheme, dark = DarkColorScheme)) { Content(data) } |
|
| 85 | + | } |
|
| 86 | + | } |
|
| 87 | + | } |
|
| 88 | + | ||
| 89 | + | class HubWidgetReceiver : GlanceAppWidgetReceiver() { |
|
| 90 | + | override val glanceAppWidget: GlanceAppWidget = HubWidget() |
|
| 91 | + | } |
|
| 92 | + | ||
| 93 | + | private val ok = ColorProvider(LightOk, DarkOk) |
|
| 94 | + | private val bad = ColorProvider(LightBad, DarkBad) |
|
| 95 | + | private val warn = ColorProvider(LightWarn, DarkWarn) |
|
| 96 | + | ||
| 97 | + | @Composable |
|
| 98 | + | private fun Content(d: WidgetData) { |
|
| 99 | + | val title = TextStyle(color = GlanceTheme.colors.primary, fontSize = 15.sp, fontWeight = FontWeight.Bold) |
|
| 100 | + | val body = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 13.sp) |
|
| 101 | + | val mute = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp) |
|
| 102 | + | Column( |
|
| 103 | + | GlanceModifier.fillMaxSize().cornerRadius(16.dp).background(GlanceTheme.colors.surfaceVariant).padding(12.dp) |
|
| 104 | + | .clickable(actionStartActivity<MainActivity>()), |
|
| 105 | + | ) { |
|
| 106 | + | Text("OxygenHub", style = title) |
|
| 107 | + | Spacer(GlanceModifier.height(6.dp)) |
|
| 108 | + | if (!d.signedIn) { |
|
| 109 | + | Text("Sign in to see your runs, inbox and reviews.", style = mute) |
|
| 110 | + | return@Column |
|
| 111 | + | } |
|
| 112 | + | if (d.runs.isEmpty()) Text("No runs yet.", style = mute) |
|
| 113 | + | d.runs.forEach { r -> |
|
| 114 | + | Row(GlanceModifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) { |
|
| 115 | + | Box(GlanceModifier.size(10.dp).cornerRadius(5.dp).background(colourOf(r.run.status))) {} |
|
| 116 | + | Spacer(GlanceModifier.width(8.dp)) |
|
| 117 | + | Text("${r.repository} #${r.run.number}", style = body, maxLines = 1, modifier = GlanceModifier.defaultWeight()) |
|
| 118 | + | Text(r.run.status, style = mute) |
|
| 119 | + | } |
|
| 120 | + | } |
|
| 121 | + | Spacer(GlanceModifier.height(6.dp)) |
|
| 122 | + | Row { |
|
| 123 | + | d.unread?.let { Text(if (it == 0L) "Inbox clear" else "$it unread", style = mute) } |
|
| 124 | + | if (d.unread != null && d.reviews != null) Text(" · ", style = mute) |
|
| 125 | + | d.reviews?.let { Text(if (it == 0) "no reviews waiting" else "$it to review", style = mute) } |
|
| 126 | + | } |
|
| 127 | + | } |
|
| 128 | + | } |
|
| 129 | + | ||
| 130 | + | private fun colourOf(status: String) = when (status) { |
|
| 131 | + | "passed" -> ok |
|
| 132 | + | "failed", "error" -> bad |
|
| 133 | + | "running", "queued" -> warn |
|
| 134 | + | else -> ColorProvider(Color.Gray, Color.Gray) |
|
| 135 | + | } |
added app/src/main/res/xml/hub_widget_info.xml +12 −0
| 1 | + | <?xml version="1.0" encoding="utf-8"?> |
|
| 2 | + | <!-- The home screen widget (#28): refreshed every half hour, and whenever the app comes to the front. --> |
|
| 3 | + | <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" |
|
| 4 | + | android:description="@string/widget_description" |
|
| 5 | + | android:initialLayout="@layout/glance_default_loading_layout" |
|
| 6 | + | android:minWidth="250dp" |
|
| 7 | + | android:minHeight="110dp" |
|
| 8 | + | android:resizeMode="horizontal|vertical" |
|
| 9 | + | android:targetCellWidth="4" |
|
| 10 | + | android:targetCellHeight="2" |
|
| 11 | + | android:updatePeriodMillis="1800000" |
|
| 12 | + | android:widgetCategory="home_screen" /> |
added app/src/test/java/io/oxygenhub/android/ui/WidgetDataTest.kt +35 −0
| 1 | + | package io.oxygenhub.android.ui |
|
| 2 | + | ||
| 3 | + | import io.oxygenhub.android.FakeHubApi |
|
| 4 | + | import io.oxygenhub.android.api.FlowRun |
|
| 5 | + | import io.oxygenhub.android.api.HubException |
|
| 6 | + | import io.oxygenhub.android.api.UserFlowRun |
|
| 7 | + | import io.oxygenhub.android.widget.WidgetData |
|
| 8 | + | import kotlinx.coroutines.test.runTest |
|
| 9 | + | import org.junit.Assert.assertEquals |
|
| 10 | + | import org.junit.Assert.assertFalse |
|
| 11 | + | import org.junit.Assert.assertNull |
|
| 12 | + | import org.junit.Test |
|
| 13 | + | ||
| 14 | + | class WidgetDataTest { |
|
| 15 | + | @Test |
|
| 16 | + | fun theWidgetShowsTheLatestRunsAndCountsAndAsksForASignInWithoutOne() = runTest { |
|
| 17 | + | val api = FakeHubApi() |
|
| 18 | + | api.userFlowRuns = { (1L..5L).map { UserFlowRun("andy/a", FlowRun(number = it, status = "passed")) } } |
|
| 19 | + | val d = WidgetData.load(api) |
|
| 20 | + | assertEquals(listOf(1L, 2L, 3L), d.runs.map { it.run.number }) |
|
| 21 | + | assertEquals(1, d.reviews) |
|
| 22 | + | assertFalse(WidgetData.load(null).signedIn) |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | @Test |
|
| 26 | + | fun aPartTheHubCannotGiveIsLeftOutNotTheWholeWidget() = runTest { |
|
| 27 | + | val api = FakeHubApi() |
|
| 28 | + | api.userFlowRuns = { throw HubException.Http(404, "no such API route") } |
|
| 29 | + | val d = WidgetData.load(api) |
|
| 30 | + | assertEquals(emptyList<UserFlowRun>(), d.runs) |
|
| 31 | + | assertEquals(1, d.reviews) |
|
| 32 | + | api.userProposals = { throw HubException.Http(500, "down") } |
|
| 33 | + | assertNull(WidgetData.load(api).reviews) |
|
| 34 | + | } |
|
| 35 | + | } |
modified .flow/pipeline.kdl +1 −1
| … | 9 unchanged lines |
||
| 10 | 10 | ||
| 11 | 11 | sandbox "android" { |
|
| 12 | 12 | runtime "microvm" |
|
| 13 | - | rootfs "cas://blob/6605d6c0b87e32667bb0b19ec473e76e7139a9f1cfa6cb36a03e773eee5af06c" |
|
| 13 | + | rootfs "cas://blob/d5dabda3166dee2a2fdaca1b245c6e677aa95066beff8b8bb829987cb2fcacd7" |
|
| 14 | 14 | memory-limit "8GiB" |
|
| 15 | 15 | cpu-limit 4 |
|
| 16 | 16 | network "isolated" |
|
| … | 92 unchanged lines |
modified CHANGELOG.md +1 −0
| … | 4 unchanged lines |
||
| 5 | 5 | - **Panels you can see (#24):** on a phone, cards and boxes were near-black on black. The app now uses OxygenHub's own colours by default (Account › Wallpaper colours turns Android's back on), every card is one panel with a 1 dp edge, as the website's have, on a tone a clear step above the background, and wallpaper colours in the dark get the same steps. A backdrop stays inside its band, and an image the app cannot show yet (a README's badge) no longer leaves a 180 dp blank. |
|
| 6 | 6 | - **Flow, at a glance (#26):** a Flow destination in the navigation: the runs of all your repositories, newest first, over a band of their sky (each repository a cluster, each run a mark) with how many are live, passed and failed; filters for live and failed; each run with its pulsing status, bookmark, trigger, who, when, how long, and a dot per job in its colour; tap for the run. Live runs refresh every 5 s while it is open. From OxygenHub's `GET /user/flow/runs`. |
|
| 7 | 7 | - **New repository, and an assistant to start it (#27, oxygenhub#347):** Repositories has a + for a new repository: under you or an organisation you belong to, with a description and visibility. When the owner has an assistant set up, "Describe what you want to create…" asks it for a starting point (a README, build files, a source layout and a first test), drafted on the hub and committed as you; the screen follows it and lists the files. The app now asks for `repo:admin` at sign-in (making repositories needs it); a phone signed in before says to sign in again. |
|
| 8 | + | - **A home screen widget (#28):** your latest three runs with their status, unread notifications and proposals waiting for your review, in OxygenHub's colours; a tap opens the app. It refreshes every half hour and whenever the app comes to the front, and asks for a sign-in when there is none. (Jetpack Glance 1.1.1.) |
|
| 8 | 9 | - **Colour on icons (#25):** icons say what they are in the hub's colours, as its pages do: repositories, folders and account details in the accent, releases, projects and pending checks in purple, private and internal locks in their warning colour, notifications by kind (a broken build red, a fixed one green, a review purple), and empty screens' icons in the accent. |
|
| 9 | 10 | ||
| 10 | 11 | ## 0.1.0 (2026-10-28) |
|
| … | 26 unchanged lines |
modified app/build.gradle.kts +2 −0
| … | 161 unchanged lines |
||
| 162 | 162 | implementation(libs.androidx.compose.material3.adaptive.layout) |
|
| 163 | 163 | implementation(libs.androidx.compose.material3.adaptive.navigation) |
|
| 164 | 164 | implementation(libs.androidx.compose.material.icons.extended) |
|
| 165 | + | implementation(libs.androidx.glance.appwidget) |
|
| 166 | + | implementation(libs.androidx.glance.material3) |
|
| 165 | 167 | implementation(libs.androidx.compose.ui) |
|
| 166 | 168 | implementation(libs.androidx.compose.ui.graphics) |
|
| 167 | 169 | implementation(libs.androidx.compose.ui.tooling.preview) |
|
| … | 23 unchanged lines |
modified app/src/main/AndroidManifest.xml +12 −0
| … | 48 unchanged lines |
||
| 49 | 49 | <action android:name="android.intent.action.MY_PACKAGE_REPLACED" /> |
|
| 50 | 50 | </intent-filter> |
|
| 51 | 51 | </receiver> |
|
| 52 | + | <!-- The home screen widget (#28): latest runs, unread, reviews waiting. --> |
|
| 53 | + | <receiver |
|
| 54 | + | android:name=".widget.HubWidgetReceiver" |
|
| 55 | + | android:exported="true" |
|
| 56 | + | android:label="@string/widget_name"> |
|
| 57 | + | <intent-filter> |
|
| 58 | + | <action android:name="android.appwidget.action.APPWIDGET_UPDATE" /> |
|
| 59 | + | </intent-filter> |
|
| 60 | + | <meta-data |
|
| 61 | + | android:name="android.appwidget.provider" |
|
| 62 | + | android:resource="@xml/hub_widget_info" /> |
|
| 63 | + | </receiver> |
|
| 52 | 64 | <!-- Alerts from the hub (#16): sealed data messages through Cloud Messaging. --> |
|
| 53 | 65 | <service |
|
| 54 | 66 | android:name=".alerts.AlertService" |
|
| … | 12 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/MainActivity.kt +11 −1
| … | 7 unchanged lines |
||
| 8 | 8 | import androidx.compose.runtime.getValue |
|
| 9 | 9 | import androidx.compose.runtime.key |
|
| 10 | 10 | import androidx.compose.runtime.mutableStateOf |
|
| 11 | + | import androidx.glance.appwidget.updateAll |
|
| 12 | + | import androidx.lifecycle.compose.collectAsStateWithLifecycle |
|
| 13 | + | import androidx.lifecycle.lifecycleScope |
|
| 11 | 14 | import io.oxygenhub.android.alerts.LandAlerts |
|
| 12 | 15 | import io.oxygenhub.android.ui.nav.ProposalRoute |
|
| 13 | - | import androidx.lifecycle.compose.collectAsStateWithLifecycle |
|
| 14 | 16 | import io.oxygenhub.android.ui.nav.SignedInApp |
|
| 15 | 17 | import io.oxygenhub.android.ui.nav.rememberViewModel |
|
| 16 | 18 | import io.oxygenhub.android.ui.signin.SignInScreen |
|
| 17 | 19 | import io.oxygenhub.android.ui.signin.SignInViewModel |
|
| 18 | 20 | import io.oxygenhub.android.ui.theme.OxygenHubTheme |
|
| 21 | + | import io.oxygenhub.android.widget.HubWidget |
|
| 22 | + | import kotlinx.coroutines.launch |
|
| 19 | 23 | import uniffi.rust_core.oxygenGreeting |
|
| 20 | 24 | ||
| 21 | 25 | class MainActivity : ComponentActivity() { |
|
| … | 30 unchanged lines |
||
| 52 | 56 | } |
|
| 53 | 57 | } |
|
| 54 | 58 | } |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | /** The widget (#28) shows what the app knows now, whenever the app comes to the front. */ |
|
| 62 | + | override fun onResume() { |
|
| 63 | + | super.onResume() |
|
| 64 | + | lifecycleScope.launch { runCatching { HubWidget().updateAll(this@MainActivity) } } |
|
| 55 | 65 | } |
|
| 56 | 66 | ||
| 57 | 67 | override fun onNewIntent(intent: Intent) { |
|
| … | 11 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/theme/Theme.kt +2 −2
| … | 13 unchanged lines |
||
| 14 | 14 | import androidx.compose.ui.graphics.lerp |
|
| 15 | 15 | import androidx.compose.ui.platform.LocalContext |
|
| 16 | 16 | ||
| 17 | - | private val DarkColorScheme = darkColorScheme( |
|
| 17 | + | internal val DarkColorScheme = darkColorScheme( |
|
| 18 | 18 | primary = DarkAccent, |
|
| 19 | 19 | onPrimary = DarkBackground, |
|
| 20 | 20 | primaryContainer = DarkBand, |
|
| … | 19 unchanged lines |
||
| 40 | 40 | error = DarkBad, |
|
| 41 | 41 | ) |
|
| 42 | 42 | ||
| 43 | - | private val LightColorScheme = lightColorScheme( |
|
| 43 | + | internal val LightColorScheme = lightColorScheme( |
|
| 44 | 44 | primary = LightAccent, |
|
| 45 | 45 | onPrimary = Color.White, |
|
| 46 | 46 | primaryContainer = LightBand, |
|
| … | 64 unchanged lines |
modified app/src/main/res/values/strings.xml +2 −0
| 1 | 1 | <resources> |
|
| 2 | 2 | <string name="app_name">OxygenHub</string> |
|
| 3 | + | <string name="widget_name">OxygenHub</string> |
|
| 4 | + | <string name="widget_description">Your latest Flow runs, unread notifications and reviews waiting</string> |
|
| 3 | 5 | </resources> |
modified gradle/libs.versions.toml +4 −0
| … | 8 unchanged lines |
||
| 9 | 9 | kotlin = "2.2.10" |
|
| 10 | 10 | composeBom = "2026.02.01" |
|
| 11 | 11 | navigation = "2.9.5" |
|
| 12 | + | # The home screen widget (#28): Jetpack Glance, Compose for app widgets. |
|
| 13 | + | glance = "1.1.1" |
|
| 12 | 14 | adaptive = "1.2.0" |
|
| 13 | 15 | kotlinxSerialization = "1.9.0" |
|
| 14 | 16 | coroutines = "1.10.2" |
|
| … | 7 unchanged lines |
||
| 22 | 24 | firebaseBom = "34.19.0" |
|
| 23 | 25 | ||
| 24 | 26 | [libraries] |
|
| 27 | + | androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" } |
|
| 28 | + | androidx-glance-material3 = { group = "androidx.glance", name = "glance-material3", version.ref = "glance" } |
|
| 25 | 29 | firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } |
|
| 26 | 30 | firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } |
|
| 27 | 31 | markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" } |
|
| … | 36 unchanged lines |