| 1 | package io.github.krolyxon.budctl |
| 2 | |
| 3 | import androidx.compose.animation.core.Animatable |
| 4 | import androidx.compose.animation.core.RepeatMode |
| 5 | import androidx.compose.animation.core.animateFloat |
| 6 | import androidx.compose.animation.core.animateFloatAsState |
| 7 | import androidx.compose.animation.core.infiniteRepeatable |
| 8 | import androidx.compose.animation.core.keyframes |
| 9 | import androidx.compose.animation.core.rememberInfiniteTransition |
| 10 | import androidx.compose.animation.core.tween |
| 11 | import androidx.compose.foundation.Canvas |
| 12 | import androidx.compose.foundation.gestures.detectHorizontalDragGestures |
| 13 | import androidx.compose.foundation.gestures.detectTapGestures |
| 14 | import androidx.compose.foundation.gestures.detectVerticalDragGestures |
| 15 | import androidx.compose.foundation.layout.Box |
| 16 | import androidx.compose.foundation.layout.BoxWithConstraints |
| 17 | import androidx.compose.foundation.layout.aspectRatio |
| 18 | import androidx.compose.foundation.layout.fillMaxWidth |
| 19 | import androidx.compose.foundation.layout.height |
| 20 | import androidx.compose.foundation.layout.size |
| 21 | import androidx.compose.foundation.layout.width |
| 22 | import androidx.compose.foundation.text.BasicText |
| 23 | import androidx.compose.runtime.Composable |
| 24 | import androidx.compose.runtime.LaunchedEffect |
| 25 | import androidx.compose.runtime.getValue |
| 26 | import androidx.compose.runtime.mutableStateOf |
| 27 | import androidx.compose.runtime.remember |
| 28 | import androidx.compose.runtime.rememberUpdatedState |
| 29 | import androidx.compose.runtime.setValue |
| 30 | import androidx.compose.ui.Alignment |
| 31 | import androidx.compose.ui.Modifier |
| 32 | import androidx.compose.ui.geometry.CornerRadius |
| 33 | import androidx.compose.ui.geometry.Offset |
| 34 | import androidx.compose.ui.geometry.Size |
| 35 | import androidx.compose.ui.graphics.Color |
| 36 | import androidx.compose.ui.graphics.lerp |
| 37 | import androidx.compose.ui.input.pointer.PointerInputChange |
| 38 | import androidx.compose.ui.input.pointer.pointerInput |
| 39 | import androidx.compose.ui.text.TextStyle |
| 40 | import androidx.compose.ui.unit.Density |
| 41 | import androidx.compose.ui.unit.Dp |
| 42 | import androidx.compose.ui.unit.IntSize |
| 43 | import androidx.compose.ui.unit.dp |
| 44 | import androidx.compose.ui.unit.sp |
| 45 | import androidx.compose.ui.util.lerp |
| 46 | import kotlin.math.PI |
| 47 | import kotlin.math.cos |
| 48 | import kotlin.math.max |
| 49 | import kotlin.math.roundToInt |
| 50 | import kotlin.math.sin |
| 51 | |
| 52 | /** The dots in a battery ring. */ |
| 53 | private const val RING_DOTS = 44 |
| 54 | |
| 55 | /** How many of [n] dots the fraction [f] lights. */ |
| 56 | private fun litCount(f: Float, n: Int) = (f.coerceIn(0f, 1f) * n).roundToInt().coerceAtMost(n) |
| 57 | |
| 58 | /** |
| 59 | * A battery ring as wide as it is given: a ring of dots, lit clockwise from the top up to the |
| 60 | * [level] (a percentage), with the level in LED dots in the middle; "--" and no lit dot when it |
| 61 | * is unknown. The dots light one after the other, like the Glyph lights, when the ring first |
| 62 | * shows and when the level changes. |
| 63 | */ |
| 64 | @Composable |
| 65 | fun Ring(level: Int?, modifier: Modifier = Modifier) { |
| 66 | val look = LocalLook.current |
| 67 | val p = look.p |
| 68 | val target = level?.let { litCount(it / 100f, RING_DOTS) } ?: 0 |
| 69 | val lit = remember { Animatable(0f) } |
| 70 | LaunchedEffect(target, look.motion) { |
| 71 | if (look.motion) lit.animateTo(target.toFloat(), tween(Motion.SWEEP, easing = Motion.EASE)) |
| 72 | else lit.snapTo(target.toFloat()) |
| 73 | } |
| 74 | BoxWithConstraints(modifier.aspectRatio(1f), contentAlignment = Alignment.Center) { |
| 75 | Canvas(Modifier.size(maxWidth)) { |
| 76 | val dot = max(size.width * 0.045f, 3.dp.toPx()) |
| 77 | val radius = size.width / 2 - dot / 2 - 1 |
| 78 | val on = lit.value.roundToInt() |
| 79 | for (n in 0 until RING_DOTS) { |
| 80 | val angle = n.toFloat() / RING_DOTS * 2 * PI.toFloat() - PI.toFloat() / 2 |
| 81 | val at = Offset(center.x + radius * cos(angle), center.y + radius * sin(angle)) |
| 82 | drawCircle(if (n < on) p.fill else p.s3, dot / 2, at) |
| 83 | } |
| 84 | } |
| 85 | // Doto is never set under 26 sp. |
| 86 | val figure = max(26f, maxWidth.value * 0.28f).sp |
| 87 | BasicText( |
| 88 | level?.toString() ?: "--", |
| 89 | style = TextStyle(fontFamily = look.fonts.dot, fontSize = figure, color = if (level != null) p.fg else p.mute), |
| 90 | ) |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | /** |
| 95 | * An on/off switch: off, a dark pill with a dim knob on the left; on, the fill runs to the right |
| 96 | * end, with a dot in it. Turned, the knob stretches into the fill (or back). It only shows the |
| 97 | * state: the row around it takes the touch. |
| 98 | */ |
| 99 | @Composable |
| 100 | fun Toggle(on: Boolean) { |
| 101 | val look = LocalLook.current |
| 102 | val p = look.p |
| 103 | val t by animateFloatAsState(if (on) 1f else 0f, Motion.eased(Motion.STATE, look.motion), label = "switch") |
| 104 | Canvas(Modifier.size(60.dp, 32.dp)) { |
| 105 | drawRoundRect(p.s2, cornerRadius = CornerRadius(size.height / 2)) |
| 106 | val pad = 4.dp.toPx() |
| 107 | val height = size.height - 2 * pad |
| 108 | val width = lerp(height, size.width - 2 * pad, t) |
| 109 | drawRoundRect(lerp(p.mute, p.fill, t), Offset(pad, pad), Size(width, height), CornerRadius(height / 2)) |
| 110 | if (t > 0f) { |
| 111 | val r = 4.5.dp.toPx() |
| 112 | drawCircle(p.fillFg.copy(alpha = p.fillFg.alpha * t), r, Offset(pad + width - 7.dp.toPx() - r, size.height / 2)) |
| 113 | } |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | /** A dotted slider's dots: the value's, and the others. */ |
| 118 | private val DOT = 5.dp |
| 119 | private val DOT_BIG = 11.dp |
| 120 | |
| 121 | /** |
| 122 | * A dotted slider over [low]..[high], one dot per step, left to right or, [vertical], bottom to |
| 123 | * top: the dots from 0 to the value are lit, and the value's own dot is larger. A touch or a drag |
| 124 | * calls [change]; lifting the finger calls [release], which is when a front end writes. |
| 125 | */ |
| 126 | @Composable |
| 127 | fun DotSlider( |
| 128 | low: Int, |
| 129 | high: Int, |
| 130 | value: Int, |
| 131 | change: (Int) -> Unit, |
| 132 | release: () -> Unit, |
| 133 | modifier: Modifier = Modifier, |
| 134 | vertical: Boolean = false, |
| 135 | ) { |
| 136 | val look = LocalLook.current |
| 137 | val p = look.p |
| 138 | val onChange by rememberUpdatedState(change) |
| 139 | val onRelease by rememberUpdatedState(release) |
| 140 | // The value's dot grows; the one it left shrinks back. |
| 141 | var left by remember { mutableStateOf<Int?>(null) } |
| 142 | var shown by remember { mutableStateOf(value) } |
| 143 | val grow = remember { Animatable(1f) } |
| 144 | LaunchedEffect(value) { |
| 145 | if (value != shown) { |
| 146 | left = shown |
| 147 | shown = value |
| 148 | grow.snapTo(0f) |
| 149 | grow.animateTo(1f, Motion.eased(Motion.HOVER, look.motion)) |
| 150 | } |
| 151 | } |
| 152 | val steps = max(1, high - low) |
| 153 | val sized = if (vertical) modifier.width(DOT_BIG + 22.dp).height(190.dp) else modifier.fillMaxWidth().height(DOT_BIG + 22.dp) |
| 154 | |
| 155 | /** 0 to 1 along the slider for a point on it. */ |
| 156 | fun Density.along(at: Offset, size: IntSize): Float { |
| 157 | val pad = DOT_BIG.toPx() |
| 158 | val along = if (vertical) (size.height - pad - at.y) / max(1f, size.height - 2 * pad) else (at.x - pad) / max(1f, size.width - 2 * pad) |
| 159 | return along.coerceIn(0f, 1f) |
| 160 | } |
| 161 | Canvas( |
| 162 | sized |
| 163 | .pointerInput(low, high, vertical) { |
| 164 | detectTapGestures { at -> |
| 165 | onChange(low + (along(at, size) * steps).roundToInt()) |
| 166 | onRelease() |
| 167 | } |
| 168 | } |
| 169 | .pointerInput(low, high, vertical) { |
| 170 | val drag = { change: PointerInputChange -> |
| 171 | change.consume() |
| 172 | onChange(low + (along(change.position, size) * steps).roundToInt()) |
| 173 | } |
| 174 | if (vertical) { |
| 175 | detectVerticalDragGestures(onDragEnd = { onRelease() }, onDragCancel = { onRelease() }) { change, _ -> drag(change) } |
| 176 | } else { |
| 177 | detectHorizontalDragGestures(onDragEnd = { onRelease() }, onDragCancel = { onRelease() }) { change, _ -> drag(change) } |
| 178 | } |
| 179 | }, |
| 180 | ) { |
| 181 | val pad = DOT_BIG.toPx() |
| 182 | val base = low.coerceAtLeast(0).coerceAtMost(high) |
| 183 | val from = minOf(base, value) |
| 184 | val to = maxOf(base, value) |
| 185 | val g = grow.value |
| 186 | for (v in low..high) { |
| 187 | val along = (v - low).toFloat() / steps |
| 188 | val at = if (vertical) { |
| 189 | Offset(size.width / 2, size.height - pad - along * (size.height - 2 * pad)) |
| 190 | } else { |
| 191 | Offset(pad + along * (size.width - 2 * pad), size.height / 2) |
| 192 | } |
| 193 | val diameter = when (v) { |
| 194 | value -> lerp(DOT.toPx(), DOT_BIG.toPx(), g) |
| 195 | left -> lerp(DOT_BIG.toPx(), DOT.toPx(), g) |
| 196 | else -> DOT.toPx() |
| 197 | } |
| 198 | drawCircle(if (v in from..to) p.fill else p.s3, diameter / 2, at) |
| 199 | } |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | /** A round light [size] across; [blinking], it is lit and dark in turn, like a recording light. */ |
| 204 | @Composable |
| 205 | fun Light(color: Color, size: Dp, blinking: Boolean = false) { |
| 206 | val look = LocalLook.current |
| 207 | val alpha = if (blinking && look.motion) { |
| 208 | val blink = rememberInfiniteTransition(label = "blink") |
| 209 | // Hard steps, no fade: lit for one beat, dark for the next. |
| 210 | val a by blink.animateFloat( |
| 211 | 1f, |
| 212 | 0f, |
| 213 | infiniteRepeatable( |
| 214 | keyframes { |
| 215 | durationMillis = 2 * Motion.BLINK |
| 216 | 1f at 0 |
| 217 | 1f at Motion.BLINK - 1 |
| 218 | 0f at Motion.BLINK |
| 219 | 0f at 2 * Motion.BLINK - 1 |
| 220 | }, |
| 221 | RepeatMode.Restart, |
| 222 | ), |
| 223 | label = "blink", |
| 224 | ) |
| 225 | a |
| 226 | } else { |
| 227 | 1f |
| 228 | } |
| 229 | Box(Modifier.size(size)) { |
| 230 | Canvas(Modifier.size(size)) { drawCircle(color.copy(alpha = color.alpha * alpha)) } |
| 231 | } |
| 232 | } |