OXYGENHUB

The widget: Needs you (the top review and newest assigned issue) and a Flow health strip of the last 30 runs; the mark in its own colours (closes #33) 943c54cf6a56

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 0c6acb718d4d
When
· 2026-11-01 19:48 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
943c54cf6a560d22648050baa883dc01acc5c08dfe60712ea53cd7bcdbf1bb49
Depends on
1b55d7f47d9f 57c0824300db 6823c243eaac aa627197ec18 b610bc6eeab7
Depended on by
d2e553089d82 fb420bbdb058 1b7703ec4b02
In bookmarks
main
In tags
v0.1.5 v0.1.6 v0.1.7 v0.1.8 v0.1.9
Edits
14 atoms, 1 tree operation, 4 files

Browse the tree at this change

added app/src/main/java/io/oxygenhub/android/widget/HealthStrip.kt +68 −0
1 +
package io.oxygenhub.android.widget
2 +
3 +
import android.graphics.Bitmap
4 +
import android.graphics.Canvas
5 +
import android.graphics.Paint
6 +
import android.graphics.RectF
7 +
import androidx.compose.ui.graphics.toArgb
8 +
import io.oxygenhub.android.ui.theme.DarkBad
9 +
import io.oxygenhub.android.ui.theme.DarkOk
10 +
import io.oxygenhub.android.ui.theme.DarkWarn
11 +
import io.oxygenhub.android.ui.theme.LightBad
12 +
import io.oxygenhub.android.ui.theme.LightOk
13 +
import io.oxygenhub.android.ui.theme.LightWarn
14 +
15 +
/**
16 +
 * The widget's Flow health strip (#33): your last runs, oldest on the left,
17 +
 * a bar each in its outcome's colour, and how many of the finished ones
18 +
 * passed. Drawn as one picture, as a widget's row holds only ten children.
19 +
 */
20 +
object HealthStrip {
21 +
    /** The kinds a run's status comes to. */
22 +
    enum class Kind { Passed, Failed, Live, Other }
23 +
24 +
    fun kind(status: String): Kind = when (status) {
25 +
        "passed" -> Kind.Passed
26 +
        "failed", "error" -> Kind.Failed
27 +
        "running", "queued" -> Kind.Live
28 +
        else -> Kind.Other
29 +
    }
30 +
31 +
    /** "93% of 28 finished runs passed", or null with nothing finished to count. Cancelled runs do not count. */
32 +
    fun summary(statuses: List<String>): String? {
33 +
        val kinds = statuses.map(::kind)
34 +
        val finished = kinds.count { it == Kind.Passed || it == Kind.Failed }
35 +
        if (finished == 0) return null
36 +
        val passed = kinds.count { it == Kind.Passed }
37 +
        val pct = (passed * 100 + finished / 2) / finished
38 +
        return if (finished == 1) "$pct% of the last finished run passed" else "$pct% of the last $finished finished runs passed"
39 +
    }
40 +
41 +
    /** The strip for [statuses] (newest first, as the hub lists them) at [width] × [height] pixels. */
42 +
    fun render(statuses: List<String>, width: Int, height: Int, density: Float, dark: Boolean): Bitmap {
43 +
        val bitmap = Bitmap.createBitmap(width.coerceAtLeast(1), height.coerceAtLeast(1), Bitmap.Config.ARGB_8888)
44 +
        val canvas = Canvas(bitmap)
45 +
        val runs = statuses.take(MAX).reversed()
46 +
        if (runs.isEmpty()) return bitmap
47 +
        val gap = 2f * density
48 +
        val bar = ((width - gap * (MAX - 1)) / MAX).coerceAtLeast(1f)
49 +
        val paint = Paint(Paint.ANTI_ALIAS_FLAG)
50 +
        // Right-aligned, so the newest run is always at the right edge.
51 +
        var x = width - runs.size * bar - (runs.size - 1) * gap
52 +
        for (s in runs) {
53 +
            paint.color = colour(kind(s), dark)
54 +
            canvas.drawRoundRect(RectF(x, 0f, x + bar, height.toFloat()), 1.5f * density, 1.5f * density, paint)
55 +
            x += bar + gap
56 +
        }
57 +
        return bitmap
58 +
    }
59 +
60 +
    private fun colour(k: Kind, dark: Boolean): Int = when (k) {
61 +
        Kind.Passed -> (if (dark) DarkOk else LightOk).toArgb()
62 +
        Kind.Failed -> (if (dark) DarkBad else LightBad).toArgb()
63 +
        Kind.Live -> (if (dark) DarkWarn else LightWarn).toArgb()
64 +
        Kind.Other -> if (dark) 0x66FFFFFF else 0x55000000
65 +
    }
66 +
67 +
    const val MAX = 30
68 +
}
modified CHANGELOG.md +4 −0
1 1
# Changelog
2 2
3 +
## Unreleased
4 +
5 +
- **Needs you and Flow health on the widget (#33):** the space under the runs shows the proposal at the head of your reviews and your newest assigned issue, a line each that opens it, and a strip of your last 30 runs (oldest on the left, each bar in its outcome's colour) with how many of the finished ones passed. The OxygenHub mark keeps its own colours again; only the word is white.
6 +
3 7
## 0.1.4 (2026-11-01)
4 8
5 9
- **This phone as your second factor (#32, oxygenhub#356):** Account › Second factor › *Use this phone as a second factor* makes a P-256 key in the Android Keystore that signs only after your fingerprint, face or PIN, and registers it with the hub (with a current code from your authenticator app, which stays the way in if the phone is lost). Signing in on the web, or confirming a sensitive change there, then asks the phone: while the app is open it shows "Is this you?" with three numbers, you pick the one the browser shows, confirm, and the browser carries on; "No, it is not me" refuses. Where the hub sends alerts, an approval wakes the phone and a tap opens the app. Needs OxygenHub with `/api/v1/user/approvals`.
…
47 unchanged lines
modified app/src/main/java/io/oxygenhub/android/widget/HubWidget.kt +57 −8
…
84 unchanged lines
85 85
    val onlyAssigned: IssueBrief? = null,
86 86
    /** What the phone runs as an OxygenFlow runner while it serves (#31); null when it is not serving. */
87 87
    val runnerJobs: List<String>? = null,
88 +
    /** The proposal at the head of your reviews, and your newest assigned issue: Needs you (#33). */
89 +
    val topReview: ProposalBrief? = null,
90 +
    val topAssigned: IssueBrief? = null,
91 +
    /** Your last runs' statuses, newest first, for the health strip (#33). */
92 +
    val health: List<String> = emptyList(),
88 93
) {
89 94
    companion object {
90 95
        /**
…
17 unchanged lines
108 113
            val sky = async { quietly { api.userSky() } }
109 114
            val review = reviews.await()
110 115
            val issues = assigned.await()
116 +
            val all = runs.await().orEmpty()
111 117
            WidgetData(
112 118
                signedIn = true,
113 -
                runs = runs.await().orEmpty().take(MAX_RUNS),
119 +
                runs = all.take(MAX_RUNS),
114 120
                unread = unread.await(),
115 121
                reviews = review?.size,
116 122
                sky = sky.await(),
…
1 unchanged lines
118 124
                assigned = issues?.size,
119 125
                onlyAssigned = issues?.singleOrNull(),
120 126
                runnerJobs = runnerJobs,
127 +
                topReview = review?.firstOrNull(),
128 +
                topAssigned = issues?.firstOrNull(),
129 +
                health = all.take(HealthStrip.MAX).map { it.run.status },
121 130
            )
122 131
        }
123 132
…
69 unchanged lines
193 202
    )
194 203
}
195 204
196 -
/** The mark and the title: white on the dark widget, the text colour on the light one (#31). */
205 +
/** The title: white on the dark widget, the text colour on the light one (#31); the mark keeps its own colours (#33). */
197 206
private val headline = ColorProvider(LightColorScheme.onSurface, Color.White)
198 207
199 208
@Composable
…
32 unchanged lines
232 241
private fun Body(d: WidgetData, title: TextStyle, body: TextStyle, mute: TextStyle) {
233 242
    Column(GlanceModifier.fillMaxSize()) {
234 243
        Row(verticalAlignment = Alignment.CenterVertically) {
235 -
            Image(
236 -
                ImageProvider(R.drawable.mark),
237 -
                contentDescription = null,
238 -
                colorFilter = ColorFilter.tint(headline),
239 -
                modifier = GlanceModifier.size(width = 28.dp, height = 14.dp),
240 -
            )
244 +
            Image(ImageProvider(R.drawable.mark), contentDescription = null, modifier = GlanceModifier.size(width = 28.dp, height = 14.dp))
241 245
            Spacer(GlanceModifier.width(6.dp))
242 246
            Text("OxygenHub", style = title)
243 247
        }
…
11 unchanged lines
255 259
                Text(r.run.status, style = mute)
256 260
            }
257 261
        }
262 +
        NeedsYou(d, body)
258 263
        Spacer(GlanceModifier.defaultWeight())
264 +
        Health(d, mute)
265 +
        Spacer(GlanceModifier.height(6.dp))
259 266
        Counts(d)
260 267
    }
261 268
}
…
23 unchanged lines
285 292
                Text(runnerLine(jobs), style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 12.sp), maxLines = 1)
286 293
            }
287 294
        }
295 +
    }
296 +
}
297 +
298 +
/** Needs you (#33): the top proposal to review and the newest issue assigned, a line each, opening it. */
299 +
@Composable
300 +
private fun NeedsYou(d: WidgetData, body: TextStyle) {
301 +
    val r = d.topReview
302 +
    val i = d.topAssigned
303 +
    if (r == null && i == null) return
304 +
    Spacer(GlanceModifier.height(8.dp))
305 +
    Text("Needs you", style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 11.sp, fontWeight = FontWeight.Bold))
306 +
    r?.let { NeedLine(R.drawable.widget_proposal, "Review ${it.repository} #${it.number}: ${it.title}", body, WidgetTaps.action("proposal", it.repository, it.number)) }
307 +
    i?.let { NeedLine(R.drawable.widget_issue, "${it.repository} #${it.number}: ${it.title}", body, WidgetTaps.action("issue", it.repository, it.number)) }
308 +
}
309 +
310 +
@Composable
311 +
private fun NeedLine(icon: Int, text: String, body: TextStyle, open: Action) {
312 +
    Row(GlanceModifier.fillMaxWidth().padding(vertical = 2.dp).clickable(open), verticalAlignment = Alignment.CenterVertically) {
313 +
        Image(ImageProvider(icon), contentDescription = null, colorFilter = ColorFilter.tint(headline), modifier = GlanceModifier.size(13.dp))
314 +
        Spacer(GlanceModifier.width(8.dp))
315 +
        Text(text, style = body, maxLines = 1)
316 +
    }
317 +
}
318 +
319 +
/** Flow health (#33): a bar per recent run, and how many of the finished ones passed. */
320 +
@Composable
321 +
private fun Health(d: WidgetData, mute: TextStyle) {
322 +
    val summary = HealthStrip.summary(d.health) ?: return
323 +
    val context = LocalContext.current
324 +
    val size = LocalSize.current
325 +
    val dark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
326 +
    val density = context.resources.displayMetrics.density
327 +
    val strip = try {
328 +
        // The widget's width less its padding, at 10 dp high.
329 +
        ImageProvider(HealthStrip.render(d.health, ((size.width.value - 24f) * density).toInt(), (10f * density).toInt(), density, dark))
330 +
    } catch (_: Throwable) {
331 +
        null
332 +
    }
333 +
    Column(GlanceModifier.fillMaxWidth().clickable(actionStartActivity<MainActivity>())) {
334 +
        strip?.let { Image(it, contentDescription = summary, contentScale = ContentScale.FillBounds, modifier = GlanceModifier.fillMaxWidth().height(10.dp)) }
335 +
        Spacer(GlanceModifier.height(3.dp))
336 +
        Text(summary, style = mute, maxLines = 1)
288 337
    }
289 338
}
290 339
…
23 unchanged lines
modified app/src/test/java/io/oxygenhub/android/ui/WidgetDataTest.kt +22 −0
…
10 unchanged lines
11 11
import io.oxygenhub.android.ui.nav.InboxRoute
12 12
import io.oxygenhub.android.ui.nav.IssueRoute
13 13
import io.oxygenhub.android.ui.nav.ProposalRoute
14 +
import io.oxygenhub.android.widget.HealthStrip
14 15
import io.oxygenhub.android.widget.SkyBitmap
15 16
import io.oxygenhub.android.widget.WidgetData
16 17
import io.oxygenhub.android.widget.WidgetTaps
…
95 unchanged lines
112 113
        assertEquals("a broken reference falls back to the dashboard", DashboardRoute, WidgetTaps.route("issue", "nonsense", 4))
113 114
        assertNull("not from the widget", WidgetTaps.route(null, null, 0))
114 115
    }
116 +
117 +
    @Test
118 +
    fun needsYouAndTheHealthStripComeFromWhatTheWidgetFetches() = runTest {
119 +
        val api = FakeHubApi()
120 +
        val statuses = listOf("running", "passed", "failed", "cancelled", "passed", "passed")
121 +
        api.userFlowRuns = { statuses.mapIndexed { i, st -> UserFlowRun("andy/a", FlowRun(number = 10L - i, status = st)) } }
122 +
        val d = WidgetData.load(api)
123 +
        assertEquals(300L, d.topReview?.number)
124 +
        assertEquals(359L, d.topAssigned?.number)
125 +
        assertEquals("all the runs fetched, not just the three shown", statuses, d.health)
126 +
        assertEquals(3, d.runs.size)
127 +
    }
128 +
129 +
    @Test
130 +
    fun theHealthSummaryCountsFinishedRunsOnly() {
131 +
        assertEquals("75% of the last 4 finished runs passed", HealthStrip.summary(listOf("running", "passed", "failed", "cancelled", "passed", "passed")))
132 +
        assertEquals("100% of the last finished run passed", HealthStrip.summary(listOf("passed", "queued")))
133 +
        assertNull("nothing finished", HealthStrip.summary(listOf("running", "cancelled")))
134 +
        assertNull(HealthStrip.summary(emptyList()))
135 +
        assertEquals(HealthStrip.Kind.Failed, HealthStrip.kind("error"))
136 +
    }
115 137
}