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", + ) + } + } +}