OXYGENHUB

The widget: the OxygenHub mark and Your sky behind it (closes #30) 6823c243eaac

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key e2fd34937393
When
· 2026-11-01 00:37 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
6823c243eaacca62a8b875b8562d8c2b19cd5bfc42609a101c4ea986da1f95ee
Depends on
0b152017dee3 1dfa631436b6 57c0824300db b610bc6eeab7 c53e328b2db4
Depended on by
fb420bbdb058 943c54cf6a56 aa627197ec18
In bookmarks
main
In tags
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
20 atoms, 1 tree operation, 5 files

Browse the tree at this change

added app/src/main/java/io/oxygenhub/android/widget/SkyBitmap.kt +124 −0
1 +
package io.oxygenhub.android.widget
2 +
3 +
import android.graphics.Bitmap
4 +
import android.graphics.Canvas
5 +
import android.graphics.LinearGradient
6 +
import android.graphics.Paint
7 +
import android.graphics.Path
8 +
import android.graphics.Shader
9 +
import androidx.compose.ui.graphics.Color
10 +
import androidx.compose.ui.graphics.toArgb
11 +
import io.oxygenhub.android.api.UserSky
12 +
import io.oxygenhub.android.ui.backdrop.Frame
13 +
import io.oxygenhub.android.ui.backdrop.STAR
14 +
import io.oxygenhub.android.ui.backdrop.skyEvents
15 +
import io.oxygenhub.android.ui.backdrop.straight
16 +
import io.oxygenhub.android.ui.theme.DarkBad
17 +
import io.oxygenhub.android.ui.theme.DarkOk
18 +
import io.oxygenhub.android.ui.theme.DarkWarn
19 +
import io.oxygenhub.android.ui.theme.LightBad
20 +
import io.oxygenhub.android.ui.theme.LightOk
21 +
import io.oxygenhub.android.ui.theme.LightWarn
22 +
import uniffi.rust_core.Backdrop as Scene
23 +
24 +
/**
25 +
 * Your sky behind the widget: the dashboard's scene, stepped in rust-core
26 +
 * until it settles and drawn once into a bitmap at the widget's size. A
27 +
 * widget cannot animate, so each refresh draws it anew from the caller's
28 +
 * own activity; it is real data, never decoration (the hub's
29 +
 * docs/backgrounds.md), and with nothing of the caller's to draw there is no
30 +
 * bitmap and the widget keeps its plain surface.
31 +
 */
32 +
internal object SkyBitmap {
33 +
    /** Steps to let a still scene settle; it says when it has. */
34 +
    private const val SETTLE_STEPS = 240
35 +
    private const val DT = 1f / 30f
36 +
37 +
    /** Whether there is anything of the caller's to draw. */
38 +
    fun drawable(sky: UserSky?): Boolean = sky != null && sky.repos.isNotEmpty() && skyEvents(sky).isNotEmpty()
39 +
40 +
    /** The scene's colours, as the app's backdrop has them for each theme: mark, lit, link, ok, warn, bad. */
41 +
    fun colours(dark: Boolean): List<Float> {
42 +
        val (mark, lit, link) = if (dark) {
43 +
            Triple(Color(0, 224, 240).copy(alpha = 0.16f), Color(0, 240, 255).copy(alpha = 0.9f), Color(0, 240, 255).copy(alpha = 0.35f))
44 +
        } else {
45 +
            Triple(Color(95, 106, 129).copy(alpha = 0.22f), Color(0, 100, 224).copy(alpha = 0.75f), Color(0, 100, 224).copy(alpha = 0.25f))
46 +
        }
47 +
        val status = if (dark) listOf(DarkOk, DarkWarn, DarkBad) else listOf(LightOk, LightWarn, LightBad)
48 +
        return (listOf(mark, lit, link) + status).flatMap { listOf(it.red, it.green, it.blue, it.alpha) }
49 +
    }
50 +
51 +
    /** The settled frame of Your sky at [widthDp] × [heightDp], or null when there is nothing to draw. */
52 +
    fun frame(sky: UserSky?, widthDp: Float, heightDp: Float, dark: Boolean): Frame? {
53 +
        if (!drawable(sky) || widthDp <= 0f || heightDp <= 0f) return null
54 +
        val scene = Scene.sky(sky!!.repos, skyEvents(sky), widthDp, heightDp, true)
55 +
        return try {
56 +
            scene.colours(colours(dark))
57 +
            var n = 0
58 +
            while (n < SETTLE_STEPS && scene.step(DT)) n++
59 +
            Frame.of(scene.frame()).takeIf { it.marks.isNotEmpty() }
60 +
        } finally {
61 +
            scene.close()
62 +
        }
63 +
    }
64 +
65 +
    /** [frame] drawn into a bitmap of [widthPx] × [heightPx] pixels, [density] pixels to the dp. */
66 +
    fun render(frame: Frame, widthPx: Int, heightPx: Int, density: Float, dark: Boolean): Bitmap {
67 +
        val bitmap = Bitmap.createBitmap(widthPx, heightPx, Bitmap.Config.ARGB_8888)
68 +
        draw(Canvas(bitmap), frame, density, dark)
69 +
        return bitmap
70 +
    }
71 +
72 +
    private fun draw(canvas: Canvas, f: Frame, density: Float, dark: Boolean) {
73 +
        val line = Paint(Paint.ANTI_ALIAS_FLAG).apply { strokeWidth = density; style = Paint.Style.STROKE }
74 +
        val l = f.links
75 +
        var i = 0
76 +
        while (i + 12 <= l.size) {
77 +
            val (ax, ay, bx, by) = listOf(l[i], l[i + 1], l[i + 6], l[i + 7]).map { it * density }
78 +
            val ca = straight(l[i + 2], l[i + 3], l[i + 4], l[i + 5]).toArgb()
79 +
            val cb = straight(l[i + 8], l[i + 9], l[i + 10], l[i + 11]).toArgb()
80 +
            line.shader = LinearGradient(ax, ay, bx, by, ca, cb, Shader.TileMode.CLAMP)
81 +
            canvas.drawLine(ax, ay, bx, by, line)
82 +
            i += 12
83 +
        }
84 +
        val fill = Paint(Paint.ANTI_ALIAS_FLAG)
85 +
        val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
86 +
        val m = f.marks
87 +
        i = 0
88 +
        while (i + 8 <= m.size) {
89 +
            val (x, y, s) = Triple(m[i] * density, m[i + 1] * density, m[i + 2] * density)
90 +
            val colour = straight(m[i + 4], m[i + 5], m[i + 6], m[i + 7])
91 +
            // On a dark widget a lit mark glows a little, as in the app.
92 +
            if (dark && colour.alpha > 0.5f) {
93 +
                fill.color = colour.copy(alpha = colour.alpha * 0.14f).toArgb()
94 +
                canvas.drawCircle(x, y, s * 2.6f, fill)
95 +
            }
96 +
            fill.color = colour.toArgb()
97 +
            ring.color = colour.toArgb()
98 +
            when (Math.round(m[i + 3])) {
99 +
                0 -> canvas.drawCircle(x, y, s, fill) // a push: a dot
100 +
                1 -> canvas.drawPath(polygon(x, y, DIAMOND, s / 0.85f), fill) // a proposal: a diamond
101 +
                2 -> canvas.drawCircle(x, y, s * 0.72f, ring.apply { strokeWidth = s * 0.56f }) // an issue: a ring
102 +
                3 -> canvas.drawPath(polygon(x, y, STAR, s), fill) // a release: a star
103 +
                4 -> canvas.drawCircle(x, y, s * 0.85f, ring.apply { strokeWidth = s * 0.24f }) // a repository: a thin ring
104 +
                else -> { // a Flow run: a square
105 +
                    val h = s / 1.15f
106 +
                    canvas.drawRect(x - h, y - h, x + h, y + h, fill)
107 +
                }
108 +
            }
109 +
            i += 8
110 +
        }
111 +
    }
112 +
113 +
    private val DIAMOND = floatArrayOf(1f, 0f, 0f, 1f, -1f, 0f, 0f, -1f)
114 +
115 +
    private fun polygon(x: Float, y: Float, unit: FloatArray, r: Float): Path = Path().apply {
116 +
        moveTo(x + unit[0] * r, y + unit[1] * r)
117 +
        var j = 2
118 +
        while (j < unit.size) {
119 +
            lineTo(x + unit[j] * r, y + unit[j + 1] * r)
120 +
            j += 2
121 +
        }
122 +
        close()
123 +
    }
124 +
}
modified CHANGELOG.md +1 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **A flashier widget (#30):** the home screen widget now has the OxygenHub mark beside its title and Your sky behind its runs, drawn by the same scene as the dashboard's backdrop, settled into a still picture at the widget's size (a widget cannot animate) and redrawn from your own activity at each refresh, with a veil so the text stays readable in light and dark. With nothing of yours to draw, it keeps the plain surface.
5 6
- **The phone runner says what it runs (#8):** while it serves, the runner card lists the jobs it is running (owner/name #run job), and its notification says "Running owner/name #7 test" instead of waiting, from OxygenFlow's `runner::running()` (pins OxygenFlow main).
6 7
- **The phone runner rests when warm (#8):** besides charging and Wi-Fi, the runner now needs the phone cool: when the system says it is throttling (moderate or worse) or the battery is above 40 °C it takes nothing more, finishes a job due within a minute and hands back any other, as off charge, and serves again once it has cooled. The card and the notification say why it waits.
7 8
- **A proposal's description is Markdown:** it showed as plain text, `##`, `**`, backticks and table pipes included, with the source's line breaks; it now renders as an issue's does (headings, emphasis, code, lists and tables, lines joined into paragraphs).
…
36 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/backdrop/Backdrop.kt +1 −1
…
216 unchanged lines
217 217
}
218 218
219 219
/** The shader's four-pointed star: two thin diamonds crossed, meeting at 1/4.5. */
220 -
private val STAR = (1f / 4.5f).let { k -> floatArrayOf(1f, 0f, k, k, 0f, 1f, -k, k, -1f, 0f, -k, -k, 0f, -1f, k, -k) }
220 +
internal val STAR = (1f / 4.5f).let { k -> floatArrayOf(1f, 0f, k, k, 0f, 1f, -k, k, -1f, 0f, -k, -k, 0f, -1f, k, -k) }
221 221
222 222
private fun DrawScope.polygon(c: Offset, colour: Color, unit: FloatArray, r: Float) {
223 223
    path.reset()
…
36 unchanged lines
modified app/src/main/java/io/oxygenhub/android/widget/HubWidget.kt +56 −7
1 1
package io.oxygenhub.android.widget
2 2
3 3
import android.content.Context
4 +
import android.content.res.Configuration
4 5
import androidx.compose.runtime.Composable
5 6
import androidx.compose.ui.graphics.Color
6 7
import androidx.compose.ui.unit.dp
7 8
import androidx.compose.ui.unit.sp
8 9
import androidx.glance.GlanceId
9 10
import androidx.glance.GlanceModifier
11 +
import androidx.glance.Image
12 +
import androidx.glance.ImageProvider
13 +
import androidx.glance.LocalContext
14 +
import androidx.glance.LocalSize
10 15
import androidx.glance.GlanceTheme
11 16
import androidx.glance.action.actionStartActivity
12 17
import androidx.glance.action.clickable
13 18
import androidx.glance.appwidget.GlanceAppWidget
14 19
import androidx.glance.appwidget.GlanceAppWidgetReceiver
20 +
import androidx.glance.appwidget.SizeMode
15 21
import androidx.glance.appwidget.cornerRadius
16 22
import androidx.glance.appwidget.provideContent
17 23
import androidx.glance.background
…
1 unchanged lines
19 25
import androidx.glance.layout.Alignment
20 26
import androidx.glance.layout.Box
21 27
import androidx.glance.layout.Column
28 +
import androidx.glance.layout.ContentScale
22 29
import androidx.glance.layout.Row
23 30
import androidx.glance.layout.Spacer
24 31
import androidx.glance.layout.fillMaxSize
…
8 unchanged lines
33 40
import androidx.glance.text.TextStyle
34 41
import io.oxygenhub.android.MainActivity
35 42
import io.oxygenhub.android.OxygenHubApp
43 +
import io.oxygenhub.android.R
36 44
import io.oxygenhub.android.api.HubApi
37 45
import io.oxygenhub.android.api.UserFlowRun
46 +
import io.oxygenhub.android.api.UserSky
38 47
import io.oxygenhub.android.ui.theme.DarkBad
39 48
import io.oxygenhub.android.ui.theme.DarkColorScheme
40 49
import io.oxygenhub.android.ui.theme.DarkOk
…
12 unchanged lines
53 62
    val runs: List<UserFlowRun> = emptyList(),
54 63
    val unread: Long? = null,
55 64
    val reviews: Int? = null,
65 +
    /** Your sky, drawn behind the content; null keeps the plain surface. */
66 +
    val sky: UserSky? = null,
56 67
) {
57 68
    companion object {
58 69
        /** Each part on its own: one the hub cannot give is left out, not the whole widget. */
…
9 unchanged lines
68 79
            val runs = async { quietly { api.userFlowRuns() } }
69 80
            val unread = async { quietly { api.inbox(limit = 1).unread } }
70 81
            val reviews = async { quietly { api.userProposals().review.size } }
71 -
            WidgetData(true, runs.await().orEmpty().take(MAX_RUNS), unread.await(), reviews.await())
82 +
            val sky = async { quietly { api.userSky() } }
83 +
            WidgetData(true, runs.await().orEmpty().take(MAX_RUNS), unread.await(), reviews.await(), sky.await())
72 84
        }
73 85
74 86
        const val MAX_RUNS = 3
75 87
    }
76 88
}
77 89
78 -
/** A home screen widget (#28): your latest runs, unread notifications and reviews waiting; a tap opens the app. */
90 +
/**
91 +
 * A home screen widget (#28): your latest runs, unread notifications and
92 +
 * reviews waiting; a tap opens the app. The OxygenHub mark heads it and Your
93 +
 * sky is drawn behind it, at the widget's own size (#30).
94 +
 */
79 95
class HubWidget : GlanceAppWidget() {
96 +
    /** Composed for each size the launcher shows it at, so the sky fits exactly. */
97 +
    override val sizeMode: SizeMode = SizeMode.Exact
98 +
80 99
    override suspend fun provideGlance(context: Context, id: GlanceId) {
81 100
        val api = (context.applicationContext as OxygenHubApp).container.sessions.api()
82 101
        val data = WidgetData.load(api)
…
16 unchanged lines
99 118
    val title = TextStyle(color = GlanceTheme.colors.primary, fontSize = 15.sp, fontWeight = FontWeight.Bold)
100 119
    val body = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 13.sp)
101 120
    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)
121 +
    val context = LocalContext.current
122 +
    val size = LocalSize.current
123 +
    val dark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
124 +
    val density = context.resources.displayMetrics.density
125 +
    val sky = if (d.signedIn) skyImage(d.sky, size.width.value, size.height.value, density, dark) else null
126 +
    Box(GlanceModifier.fillMaxSize().cornerRadius(16.dp).background(GlanceTheme.colors.surfaceVariant).clickable(actionStartActivity<MainActivity>())) {
127 +
        if (sky != null) {
128 +
            Image(sky, contentDescription = null, contentScale = ContentScale.Crop, modifier = GlanceModifier.fillMaxSize())
129 +
            // Text stays readable over the marks.
130 +
            Box(GlanceModifier.fillMaxSize().background(scrim)) {}
131 +
        }
132 +
        Column(GlanceModifier.fillMaxSize().padding(12.dp)) { Body(d, title, body, mute) }
133 +
    }
134 +
}
135 +
136 +
/** The widget's sky as an image, or null when there is nothing to draw or drawing fails (the plain surface stays). */
137 +
private fun skyImage(sky: UserSky?, widthDp: Float, heightDp: Float, density: Float, dark: Boolean): ImageProvider? = try {
138 +
    SkyBitmap.frame(sky, widthDp, heightDp, dark)?.let {
139 +
        ImageProvider(SkyBitmap.render(it, (widthDp * density).toInt().coerceAtLeast(1), (heightDp * density).toInt().coerceAtLeast(1), density, dark))
140 +
    }
141 +
} catch (_: Throwable) {
142 +
    // A missing native library, or a launcher's odd size: the widget is worth more than its backdrop.
143 +
    null
144 +
}
145 +
146 +
private val scrim = ColorProvider(LightColorScheme.surfaceVariant.copy(alpha = 0.55f), DarkColorScheme.surfaceVariant.copy(alpha = 0.5f))
147 +
148 +
@Composable
149 +
private fun Body(d: WidgetData, title: TextStyle, body: TextStyle, mute: TextStyle) {
150 +
    Column {
151 +
        Row(verticalAlignment = Alignment.CenterVertically) {
152 +
            Image(ImageProvider(R.drawable.mark), contentDescription = null, modifier = GlanceModifier.size(width = 28.dp, height = 14.dp))
153 +
            Spacer(GlanceModifier.width(6.dp))
154 +
            Text("OxygenHub", style = title)
155 +
        }
107 156
        Spacer(GlanceModifier.height(6.dp))
108 157
        if (!d.signedIn) {
109 158
            Text("Sign in to see your runs, inbox and reviews.", style = mute)
…
26 unchanged lines
modified app/src/test/java/io/oxygenhub/android/ui/WidgetDataTest.kt +34 −0
…
3 unchanged lines
4 4
import io.oxygenhub.android.api.FlowRun
5 5
import io.oxygenhub.android.api.HubException
6 6
import io.oxygenhub.android.api.UserFlowRun
7 +
import io.oxygenhub.android.api.UserSky
8 +
import io.oxygenhub.android.widget.SkyBitmap
7 9
import io.oxygenhub.android.widget.WidgetData
8 10
import kotlinx.coroutines.test.runTest
9 11
import org.junit.Assert.assertEquals
10 12
import org.junit.Assert.assertFalse
13 +
import org.junit.Assert.assertNotEquals
11 14
import org.junit.Assert.assertNull
15 +
import org.junit.Assert.assertTrue
12 16
import org.junit.Test
13 17
14 18
class WidgetDataTest {
…
17 unchanged lines
32 36
        api.userProposals = { throw HubException.Http(500, "down") }
33 37
        assertNull(WidgetData.load(api).reviews)
34 38
    }
39 +
40 +
    @Test
41 +
    fun theWidgetFetchesYourSkyAndALostSkyLeavesThePlainSurface() = runTest {
42 +
        val api = FakeHubApi()
43 +
        val sky = UserSky(now = 10 * 86_400_000L, repos = listOf("andy/a"), events = listOf(listOf(0L, 0L, 9 * 86_400_000L, 3L)))
44 +
        api.userSky = { sky }
45 +
        assertEquals(sky, WidgetData.load(api).sky)
46 +
        api.userSky = { throw HubException.Http(404, "no such API route") }
47 +
        val d = WidgetData.load(api)
48 +
        assertNull(d.sky)
49 +
        assertEquals(1, d.reviews)
50 +
    }
51 +
52 +
    @Test
53 +
    fun onlyRealActivityIsDrawn() {
54 +
        assertFalse("nothing fetched", SkyBitmap.drawable(null))
55 +
        assertFalse("no repositories", SkyBitmap.drawable(UserSky()))
56 +
        assertFalse("repositories but no events", SkyBitmap.drawable(UserSky(repos = listOf("andy/a"))))
57 +
        assertFalse("only malformed events", SkyBitmap.drawable(UserSky(repos = listOf("andy/a"), events = listOf(listOf(7L, 0L, 0L, 1L)))))
58 +
        assertTrue(SkyBitmap.drawable(UserSky(now = 1, repos = listOf("andy/a"), events = listOf(listOf(0L, 4L, 0L, 1L)))))
59 +
    }
60 +
61 +
    @Test
62 +
    fun theSkysColoursFollowTheTheme() {
63 +
        val (light, dark) = SkyBitmap.colours(false) to SkyBitmap.colours(true)
64 +
        assertEquals(24, light.size)
65 +
        assertEquals(24, dark.size)
66 +
        assertNotEquals(light, dark)
67 +
        assertTrue("every channel is a fraction", (light + dark).all { it in 0f..1f })
68 +
    }
35 69
}