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