OXYGENHUB

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