mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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
|
||||
|
||||
+9
-9
@@ -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()
|
||||
|
||||
+10
-9
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user