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 | } |