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.
This commit is contained in:
darken
2026-08-25 22:33:42 +02:00
committed by Matthias Urhahn
parent 0f95538f39
commit 7960144be6
7 changed files with 231 additions and 41 deletions
@@ -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
}
@@ -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,
)
}
}
@@ -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),
)
@@ -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
@@ -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()
@@ -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,
)
}
}
@@ -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<Palette> = 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",
)
}
}
}