From 7960144be6ca9b643875ce85b0f1c4ca395c1b38 Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 25 Aug 2026 20:29:11 +0200 Subject: [PATCH] ui(overview): Give a low battery a colour that means "low" The warn band was drawn in colorScheme.tertiary, which is whatever the palette seed produces: olive under the amber theme, where the healthy band (primary) is burnt orange, so a warning read as decoration and healthy read as a warning. The warn band now uses fixed light/dark tokens, and the percentage text is tinted at warn and critical instead of the level living in the gauge alone. Values are derived against the composited backgrounds the cards actually draw on: the gauge Surface at 4dp tonal elevation, and the same surface at alpha 0.7 that a card without live data uses. Measuring against raw surface would have admitted values below 3:1 on screen. The tokens are resolved from the theme mode CapodTheme already computed, not from isSystemInDarkTheme(), so an in-app dark override on a light system does not pick the light tokens. --- .../capod/common/theming/BatteryColors.kt | 60 +++++++++ .../darken/capod/common/theming/CapodTheme.kt | 13 +- .../main/ui/overview/cards/DualPodsCard.kt | 24 ++-- .../main/ui/overview/cards/SinglePodsCard.kt | 17 ++- .../cards/components/BatteryCapsule.kt | 18 +-- .../cards/components/CompactBatterySummary.kt | 19 +-- .../theming/BatteryColorsContrastTest.kt | 121 ++++++++++++++++++ 7 files changed, 231 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/common/theming/BatteryColors.kt create mode 100644 app/src/test/java/eu/darken/capod/common/theming/BatteryColorsContrastTest.kt diff --git a/app/src/main/java/eu/darken/capod/common/theming/BatteryColors.kt b/app/src/main/java/eu/darken/capod/common/theming/BatteryColors.kt new file mode 100644 index 00000000..3dd806b9 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/theming/BatteryColors.kt @@ -0,0 +1,60 @@ +package eu.darken.capod.common.theming + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import eu.darken.capod.monitor.core.battery.BatteryTier + +/** + * Battery colours that must not move with the palette. `colorScheme.tertiary` is whatever the seed + * happens to produce (olive under the amber theme, teal under blue), so a level warning drawn from + * it reads as decoration rather than as a warning. + * + * The values are picked against the composited backgrounds the overview actually draws on, see + * `BatteryColorsContrastTest`. Critical stays on `colorScheme.error` and good on + * `colorScheme.primary` — both already carry the meaning we want. + */ +@Immutable +data class BatteryColors( + val warnFill: Color, + val warnText: Color, + val positiveText: Color, +) { + companion object { + val Light = BatteryColors( + warnFill = Color(0xFF874400), + warnText = Color(0xFF522300), + positiveText = Color(0xFF003B0C), + ) + val Dark = BatteryColors( + warnFill = Color(0xFFFFA726), + warnText = Color(0xFFFFCC80), + positiveText = Color(0xFFA5D6A7), + ) + } +} + +/** + * Provided by [CapodTheme] from the theme mode it already resolved. Reading + * `isSystemInDarkTheme()` here instead would pick light tokens for an in-app dark override on a + * light system. + */ +val LocalBatteryColors = staticCompositionLocalOf { BatteryColors.Light } + +@Composable +fun BatteryTier.fillColor(): Color = when (this) { + BatteryTier.UNKNOWN -> MaterialTheme.colorScheme.surfaceVariant + BatteryTier.CRITICAL -> MaterialTheme.colorScheme.error + BatteryTier.WARN -> LocalBatteryColors.current.warnFill + BatteryTier.GOOD -> MaterialTheme.colorScheme.primary +} + +/** Null where the level makes no claim, so callers keep their own default text colour. */ +@Composable +fun BatteryTier.textColorOrNull(): Color? = when (this) { + BatteryTier.CRITICAL -> MaterialTheme.colorScheme.error + BatteryTier.WARN -> LocalBatteryColors.current.warnText + BatteryTier.UNKNOWN, BatteryTier.GOOD -> null +} diff --git a/app/src/main/java/eu/darken/capod/common/theming/CapodTheme.kt b/app/src/main/java/eu/darken/capod/common/theming/CapodTheme.kt index f6841604..43877cba 100644 --- a/app/src/main/java/eu/darken/capod/common/theming/CapodTheme.kt +++ b/app/src/main/java/eu/darken/capod/common/theming/CapodTheme.kt @@ -6,6 +6,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext @@ -32,8 +33,12 @@ fun CapodTheme( } } - MaterialTheme( - colorScheme = colorScheme, - content = content, - ) + CompositionLocalProvider( + LocalBatteryColors provides if (darkTheme) BatteryColors.Dark else BatteryColors.Light, + ) { + MaterialTheme( + colorScheme = colorScheme, + content = content, + ) + } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt index b3ca9b78..a5a73f59 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt @@ -29,6 +29,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ElevatedCard import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedIconButton import androidx.compose.material3.Surface @@ -59,14 +60,19 @@ import eu.darken.capod.common.SystemTimeSource import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.common.compose.preview.MockPodDataProvider +import eu.darken.capod.common.theming.fillColor +import eu.darken.capod.common.theming.textColorOrNull import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.monitor.core.battery.BatteryEstimate +import eu.darken.capod.monitor.core.battery.BatteryTier +import eu.darken.capod.monitor.core.battery.batteryTier import eu.darken.capod.monitor.core.cachedBatteryFormatted import eu.darken.capod.pods.core.apple.aap.AapPodState import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods.LidState import eu.darken.capod.pods.core.apple.ble.devices.HasPodStyle +import eu.darken.capod.pods.core.apple.ble.batteryProgress import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent import java.time.Instant @@ -313,19 +319,15 @@ private fun PodGauge( untilCharged: String? = null, ) { val context = LocalContext.current - val clamped = if (batteryPercent >= 0f) batteryPercent.coerceIn(0f, 1f) else -1f + val tier = batteryTier(batteryPercent) + val isKnown = tier != BatteryTier.UNKNOWN val animatedProgress by animateFloatAsState( - targetValue = if (clamped >= 0f) clamped else 0f, + targetValue = batteryProgress(batteryPercent), animationSpec = tween(600, easing = FastOutSlowInEasing), label = "gaugeProgress", ) - val ringColor = when { - clamped < 0f -> MaterialTheme.colorScheme.surfaceVariant - clamped > 0.30f -> MaterialTheme.colorScheme.primary - clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary - else -> MaterialTheme.colorScheme.error - } + val ringColor = tier.fillColor() Column( modifier = modifier, @@ -347,7 +349,7 @@ private fun PodGauge( ) // Progress ring - if (clamped >= 0f) { + if (isKnown) { CircularProgressIndicator( progress = { animatedProgress }, modifier = Modifier.size(68.dp), @@ -373,7 +375,7 @@ private fun PodGauge( Text( text = formatBatteryPercent(context, batteryPercent), style = MaterialTheme.typography.titleMedium, - color = if (batteryPercent >= 0f) { + color = tier.textColorOrNull() ?: if (isKnown) { MaterialTheme.colorScheme.onSurface } else { MaterialTheme.colorScheme.onSurfaceVariant @@ -414,6 +416,7 @@ private fun CaseRow( device: PodDevice, ) { val context = LocalContext.current + val tier = batteryTier(device.batteryCase) Row( verticalAlignment = Alignment.CenterVertically, @@ -430,6 +433,7 @@ private fun CaseRow( Text( text = formatBatteryPercent(context, device.batteryCase), style = MaterialTheme.typography.bodyMedium, + color = tier.textColorOrNull() ?: LocalContentColor.current, modifier = Modifier.padding(end = 8.dp), ) diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt index 9d544e85..8b1a35fc 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt @@ -56,15 +56,18 @@ import eu.darken.capod.common.SystemTimeSource import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.common.compose.preview.MockPodDataProvider +import eu.darken.capod.common.theming.fillColor +import eu.darken.capod.common.theming.textColorOrNull import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.monitor.core.battery.BatteryEstimate +import eu.darken.capod.monitor.core.battery.BatteryTier +import eu.darken.capod.monitor.core.battery.batteryTier import eu.darken.capod.monitor.core.cachedBatteryFormatted import eu.darken.capod.pods.core.apple.aap.AapPodState import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import eu.darken.capod.pods.core.apple.ble.batteryProgress import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent -import eu.darken.capod.pods.core.apple.ble.isKnownBattery import java.time.Instant @OptIn(ExperimentalLayoutApi::class) @@ -200,19 +203,15 @@ private fun ColumnScope.SinglePodsCardExpanded( val context = LocalContext.current val percent = device.batteryHeadset - val isKnown = isKnownBattery(percent) + val tier = batteryTier(percent) + val isKnown = tier != BatteryTier.UNKNOWN val animatedProgress by animateFloatAsState( targetValue = batteryProgress(percent), animationSpec = tween(600, easing = FastOutSlowInEasing), label = "gaugeProgress", ) - val ringColor = when { - !isKnown -> MaterialTheme.colorScheme.surfaceVariant - percent > 0.30f -> MaterialTheme.colorScheme.primary - percent >= 0.15f -> MaterialTheme.colorScheme.tertiary - else -> MaterialTheme.colorScheme.error - } + val ringColor = tier.fillColor() Spacer(modifier = Modifier.height(12.dp)) @@ -261,7 +260,7 @@ private fun ColumnScope.SinglePodsCardExpanded( Text( text = formatBatteryPercent(context, percent), style = MaterialTheme.typography.headlineSmall, - color = if (isKnown) { + color = tier.textColorOrNull() ?: if (isKnown) { MaterialTheme.colorScheme.onSurface } else { MaterialTheme.colorScheme.onSurfaceVariant diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/BatteryCapsule.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/BatteryCapsule.kt index b821782c..21c62be5 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/BatteryCapsule.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/BatteryCapsule.kt @@ -18,6 +18,10 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.PreviewWrapper +import eu.darken.capod.common.theming.fillColor +import eu.darken.capod.monitor.core.battery.BatteryTier +import eu.darken.capod.monitor.core.battery.batteryTier +import eu.darken.capod.pods.core.apple.ble.batteryProgress private val CapsuleShape = RoundedCornerShape(6.dp) @@ -26,26 +30,22 @@ fun BatteryCapsule( percent: Float, modifier: Modifier = Modifier, ) { - val clamped = if (percent >= 0f) percent.coerceIn(0f, 1f) else -1f + val tier = batteryTier(percent) + val isKnown = tier != BatteryTier.UNKNOWN val animatedFraction by animateFloatAsState( - targetValue = if (clamped >= 0f) clamped else 0f, + targetValue = batteryProgress(percent), animationSpec = tween(600, easing = FastOutSlowInEasing), label = "batteryFill", ) - val barColor = when { - clamped < 0f -> MaterialTheme.colorScheme.surfaceVariant - clamped > 0.30f -> MaterialTheme.colorScheme.primary - clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary - else -> MaterialTheme.colorScheme.error - } + val barColor = tier.fillColor() Box( modifier = modifier .clip(CapsuleShape) .background(MaterialTheme.colorScheme.surfaceVariant), ) { - if (clamped >= 0f) { + if (isKnown) { Box( modifier = Modifier .fillMaxHeight() diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/CompactBatterySummary.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/CompactBatterySummary.kt index 2d9447e8..4513c568 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/CompactBatterySummary.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/CompactBatterySummary.kt @@ -32,7 +32,11 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import eu.darken.capod.R +import eu.darken.capod.common.theming.fillColor +import eu.darken.capod.common.theming.textColorOrNull import eu.darken.capod.monitor.core.PodDevice +import eu.darken.capod.monitor.core.battery.BatteryTier +import eu.darken.capod.monitor.core.battery.batteryTier import eu.darken.capod.pods.core.apple.ble.batteryProgress import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent import eu.darken.capod.pods.core.apple.ble.isKnownBattery @@ -127,19 +131,15 @@ private fun MiniPodRing( modifier: Modifier = Modifier, ) { val context = LocalContext.current - val isKnown = isKnownBattery(percent) + val tier = batteryTier(percent) + val isKnown = tier != BatteryTier.UNKNOWN val animatedProgress by animateFloatAsState( targetValue = batteryProgress(percent), animationSpec = tween(600, easing = FastOutSlowInEasing), label = "miniGaugeProgress", ) - val ringColor = when { - !isKnown -> MaterialTheme.colorScheme.surfaceVariant - percent > 0.30f -> MaterialTheme.colorScheme.primary - percent >= 0.15f -> MaterialTheme.colorScheme.tertiary - else -> MaterialTheme.colorScheme.error - } + val ringColor = tier.fillColor() Row( modifier = modifier, @@ -179,7 +179,7 @@ private fun MiniPodRing( Text( text = formatBatteryPercent(context, percent), style = MaterialTheme.typography.titleSmall, - color = if (isKnown) { + color = tier.textColorOrNull() ?: if (isKnown) { MaterialTheme.colorScheme.onSurface } else { MaterialTheme.colorScheme.onSurfaceVariant @@ -194,6 +194,7 @@ private fun MiniCaseCluster( modifier: Modifier = Modifier, ) { val context = LocalContext.current + val tier = batteryTier(device.batteryCase) Row( modifier = modifier, @@ -215,7 +216,7 @@ private fun MiniCaseCluster( Text( text = formatBatteryPercent(context, device.batteryCase), style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, + color = tier.textColorOrNull() ?: MaterialTheme.colorScheme.onSurface, ) } } diff --git a/app/src/test/java/eu/darken/capod/common/theming/BatteryColorsContrastTest.kt b/app/src/test/java/eu/darken/capod/common/theming/BatteryColorsContrastTest.kt new file mode 100644 index 00000000..52c1d5db --- /dev/null +++ b/app/src/test/java/eu/darken/capod/common/theming/BatteryColorsContrastTest.kt @@ -0,0 +1,121 @@ +package eu.darken.capod.common.theming + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.unit.dp +import io.kotest.assertions.withClue +import io.kotest.matchers.doubles.shouldBeGreaterThanOrEqual +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +/** + * The overview's battery gauges sit on a `Surface(tonalElevation = 4.dp)` inside an `ElevatedCard`, + * and a card without live data is drawn at `alpha(0.7f)`. Both variants are measured here: checking + * a token against the raw `surface` colour would pass values that are unreadable on screen. + * + * Material You is dynamic and can't be enumerated, so the tokens are fixed and verified against the + * full spread of the bundled palettes instead. + */ +class BatteryColorsContrastTest : BaseTest() { + + private val gaugeElevation = 4.dp + private val notLiveAlpha = 0.7f + + /** WCAG 2.x minimum for a graphical object such as a gauge fill. */ + private val fillMinimum = 3.0 + + /** WCAG 2.x minimum for text. */ + private val textMinimum = 4.5 + + private data class Palette( + val name: String, + val scheme: ColorScheme, + val tokens: BatteryColors, + ) { + /** Container colour of the ElevatedCard the gauges are drawn in. */ + val cardBackground: Color get() = scheme.surfaceContainerLow + } + + private val palettes: List = buildList { + ThemeColor.entries.forEach { color -> + ThemeStyle.entries.filter { it != ThemeStyle.MATERIAL_YOU }.forEach { style -> + add( + Palette( + name = "$color/$style/light", + scheme = ThemeColorProvider.getLightColorScheme(color, style), + tokens = BatteryColors.Light, + ) + ) + add( + Palette( + name = "$color/$style/dark", + scheme = ThemeColorProvider.getDarkColorScheme(color, style), + tokens = BatteryColors.Dark, + ) + ) + } + } + } + + private fun contrast(a: Color, b: Color): Double { + val brighter = maxOf(a.luminance(), b.luminance()).toDouble() + val darker = minOf(a.luminance(), b.luminance()).toDouble() + return (brighter + 0.05) / (darker + 0.05) + } + + private fun Color.dimmed(over: Color): Color = copy(alpha = notLiveAlpha).compositeOver(over) + + private fun Palette.assertContrast(token: Color, against: Color, minimum: Double, what: String) { + withClue("$what, $name") { + contrast(token, against) shouldBeGreaterThanOrEqual minimum + } + withClue("$what, $name, card without live data") { + contrast(token.dimmed(cardBackground), against.dimmed(cardBackground)) shouldBeGreaterThanOrEqual minimum + } + } + + @Test + fun `every bundled palette is covered`() { + palettes.size shouldBe 18 + } + + @Test + fun `the warn fill stands out from the gauge track`() { + palettes.forEach { + it.assertContrast( + token = it.tokens.warnFill, + against = it.scheme.surfaceVariant, + minimum = fillMinimum, + what = "warn fill", + ) + } + } + + @Test + fun `the warn text is readable on the gauge surface`() { + palettes.forEach { + it.assertContrast( + token = it.tokens.warnText, + against = it.scheme.surfaceColorAtElevation(gaugeElevation), + minimum = textMinimum, + what = "warn text", + ) + } + } + + @Test + fun `the positive text is readable on the gauge surface`() { + palettes.forEach { + it.assertContrast( + token = it.tokens.positiveText, + against = it.scheme.surfaceColorAtElevation(gaugeElevation), + minimum = textMinimum, + what = "positive text", + ) + } + } +}