Coverage / app/src/main/java/io/oxygenhub/android/widget/SkyBitmap.kt 12.1%
7 of 58 lines run, 3 of 7 functions, 14 of 46 branches · run #1662 on main · run missed
| 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 | } |