From 11d6ddf3faa54a57e3c030955e80250fbd7624ea Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 25 Aug 2026 20:35:32 +0200 Subject: [PATCH] feat(overview): Say how many earbud charges the case still holds The case percentage alone doesn't answer the question people actually have, which is whether the case can top the earbuds up again. The card now says so in words, below the case row. The reading is treated as the interval it really is: a decile source at 20% on a 4.0-charge case means 0.8 to 1.2 charges, which is not an answer, so the line stays neutral instead of flipping colour as the reading bounces between adjacent frames. Only an interval that lies wholly above or below one full charge is coloured, which removes the need to remember anything between frames. The line is stacked rather than inline: the case row already has four children with only the capsule weighted, so a plural in front of it would collapse the capsule at large font scales and in locales with long plural forms. --- .../compose/preview/MockPodDataProvider.kt | 45 ++++ .../main/ui/overview/cards/DualPodsCard.kt | 220 +++++++++++++----- .../main/ui/overview/cards/SinglePodsCard.kt | 20 ++ .../capod/monitor/core/battery/CaseCharges.kt | 60 +++++ app/src/main/res/values/strings.xml | 14 ++ .../monitor/core/battery/CaseChargesTest.kt | 110 +++++++++ 6 files changed, 415 insertions(+), 54 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/monitor/core/battery/CaseCharges.kt create mode 100644 app/src/test/java/eu/darken/capod/monitor/core/battery/CaseChargesTest.kt diff --git a/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt b/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt index 18b8cfde..333bec1b 100644 --- a/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt +++ b/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt @@ -264,6 +264,51 @@ object MockPodDataProvider { ), ) + /** + * Dual pods with hand-picked battery levels. The case reads at decile granularity, like the + * public advertisement it would come from. + */ + fun dualPodBatteries( + left: Float? = 0.80f, + right: Float? = 0.45f, + case: Float? = 0.60f, + model: PodModel = PodModel.AIRPODS_PRO2, + label: String = "My AirPods Pro", + ): PodDevice = PodDevice( + profileId = "preview-dual-batteries", + label = label, + ble = MockDualBlePodSnapshot( + _model = model, + _label = label, + batteryLeftPodPercent = left, + batteryRightPodPercent = right, + _batteryCasePercent = case, + leftPodIcon = model.leftPodIconRes ?: R.drawable.device_airpods_gen1_left, + rightPodIcon = model.rightPodIconRes ?: R.drawable.device_airpods_gen1_right, + _caseIcon = model.caseIconRes ?: R.drawable.device_airpods_gen1_case, + _address = "AA:BB:CC:DD:EE:FF", + ), + aap = null, + ) + + /** A single-pod device with a hand-picked battery level. */ + fun singlePodBattery( + percent: Float, + model: PodModel = PodModel.AIRPODS_MAX, + label: String = "AirPods Max", + ): PodDevice = PodDevice( + profileId = "preview-single-battery", + label = label, + ble = MockSingleBlePodSnapshot( + _model = model, + _label = label, + batteryHeadsetPercent = percent, + _isBeingWorn = true, + _address = "AA:BB:CC:DD:EE:FF", + ), + aap = null, + ) + fun singlePodMonitored(): PodDevice = PodDevice( profileId = "preview-single", label = "AirPods Max", 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 9052f952..2d220a2f 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 @@ -42,7 +42,10 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import eu.darken.capod.R @@ -61,13 +64,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.LocalBatteryColors 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.batteryCaseReading import eu.darken.capod.monitor.core.battery.BatteryEstimate import eu.darken.capod.monitor.core.battery.BatteryTier +import eu.darken.capod.monitor.core.battery.CaseCharges import eu.darken.capod.monitor.core.battery.batteryTier +import eu.darken.capod.monitor.core.battery.caseCharges import eu.darken.capod.monitor.core.cachedBatteryFormatted +import eu.darken.capod.pods.core.apple.PodModel 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 @@ -418,69 +426,125 @@ private fun CaseRow( ) { val context = LocalContext.current val tier = batteryTier(device.batteryCase) + val charges = caseCharges(device.model.caseSpec, device.batteryCaseReading) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .batteryTierState(tier), - ) { - Image( - painter = painterResource(device.caseIcon), - contentDescription = null, - modifier = Modifier.size(28.dp), - ) - - Spacer(modifier = Modifier.width(8.dp)) - - Text( - text = formatBatteryPercent(context, device.batteryCase), - style = MaterialTheme.typography.bodyMedium, - color = tier.textColorOrNull() ?: LocalContentColor.current, - modifier = Modifier.padding(end = 8.dp), - ) - - BatteryCapsule( - percent = device.batteryCase, + Column(modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .weight(1f) - .height(8.dp), - ) - - Spacer(modifier = Modifier.width(8.dp)) - - FlowRow( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + .fillMaxWidth() + .batteryTierState(tier), ) { - when (val caseState = device.caseChargingState - ?: device.isCaseCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null }) { - AapPodState.ChargingState.CHARGING_OPTIMIZED -> StatusChip( - icon = Icons.TwoTone.BatteryChargingFull, - label = stringResource(R.string.pods_charging_optimized_label), - ) - AapPodState.ChargingState.CHARGING -> StatusChip( - icon = Icons.TwoTone.BatteryChargingFull, - label = stringResource(R.string.pods_charging_label), - ) - else -> Unit - } + Image( + painter = painterResource(device.caseIcon), + contentDescription = null, + modifier = Modifier.size(28.dp), + ) - val lidState = device.caseLidState - if (lidState == LidState.OPEN || lidState == LidState.CLOSED) { - StatusChip( - icon = Icons.TwoTone.GridView, - label = when (lidState) { - LidState.OPEN -> stringResource(R.string.pods_case_status_open_label) - LidState.CLOSED -> stringResource(R.string.pods_case_status_closed_label) - else -> "" - }, - ) + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = formatBatteryPercent(context, device.batteryCase), + style = MaterialTheme.typography.bodyMedium, + color = tier.textColorOrNull() ?: LocalContentColor.current, + modifier = Modifier.padding(end = 8.dp), + ) + + BatteryCapsule( + percent = device.batteryCase, + modifier = Modifier + .weight(1f) + .height(8.dp), + ) + + Spacer(modifier = Modifier.width(8.dp)) + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + when (val caseState = device.caseChargingState + ?: device.isCaseCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null }) { + AapPodState.ChargingState.CHARGING_OPTIMIZED -> StatusChip( + icon = Icons.TwoTone.BatteryChargingFull, + label = stringResource(R.string.pods_charging_optimized_label), + ) + AapPodState.ChargingState.CHARGING -> StatusChip( + icon = Icons.TwoTone.BatteryChargingFull, + label = stringResource(R.string.pods_charging_label), + ) + else -> Unit + } + + val lidState = device.caseLidState + if (lidState == LidState.OPEN || lidState == LidState.CLOSED) { + StatusChip( + icon = Icons.TwoTone.GridView, + label = when (lidState) { + LidState.OPEN -> stringResource(R.string.pods_case_status_open_label) + LidState.CLOSED -> stringResource(R.string.pods_case_status_closed_label) + else -> "" + }, + ) + } } } + + if (charges != null) { + CaseChargesLine( + charges = charges, + // Aligned under the percentage, past the case icon and its spacer. + modifier = Modifier.padding(start = 36.dp, top = 2.dp), + ) + } } } +/** + * Stacked below the case row rather than inline: that row already carries four children of which + * only the capsule is weighted, so an unbounded plural in front of it squeezes the capsule away at + * large font scales and in locales with long plural forms. + */ +@Composable +private fun CaseChargesLine( + charges: CaseCharges, + modifier: Modifier = Modifier, +) { + val text = when (charges.display) { + CaseCharges.Display.EMPTY -> stringResource(R.string.battery_case_charges_empty) + CaseCharges.Display.LESS_THAN_ONE -> stringResource(R.string.battery_case_charges_less_than_one) + CaseCharges.Display.AT_LEAST -> pluralStringResource( + R.plurals.battery_case_charges_at_least, + charges.count, + charges.count, + ) + CaseCharges.Display.APPROXIMATE -> pluralStringResource( + R.plurals.battery_case_charges_approx, + charges.count, + charges.count, + ) + } + // The same visible count can be adequate or not depending on how coarse the reading was, so + // the adequacy needs its own announcement rather than riding on the case battery's state. + val state = when (charges.adequacy) { + CaseCharges.Adequacy.ENOUGH -> stringResource(R.string.battery_case_charges_state_enough_cd) + CaseCharges.Adequacy.NOT_ENOUGH -> stringResource(R.string.battery_case_charges_state_not_enough_cd) + CaseCharges.Adequacy.UNCERTAIN -> stringResource(R.string.battery_case_charges_state_uncertain_cd) + } + val color = when (charges.adequacy) { + CaseCharges.Adequacy.ENOUGH -> LocalBatteryColors.current.positiveText + CaseCharges.Adequacy.NOT_ENOUGH -> LocalBatteryColors.current.warnText + CaseCharges.Adequacy.UNCERTAIN -> MaterialTheme.colorScheme.onSurfaceVariant + } + + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = color, + modifier = modifier.semantics { stateDescription = state }, + ) +} + @Preview2 @Composable private fun DualPodsCardFullPreview() = PreviewWrapper { @@ -532,6 +596,54 @@ private fun DualPodsCardEstimateProvisionalPreview() = PreviewWrapper { ) } +@Preview2 +@Composable +private fun DualPodsCardWarnPreview() = PreviewWrapper { + // Case at 30% of a 4.0-charge spec: even the bottom of the decile band clears a full charge. + DualPodsCard( + device = MockPodDataProvider.dualPodBatteries(left = 0.22f, right = 0.18f, case = 0.30f), + showDebug = false, + now = SystemTimeSource.now(), + ) +} + +@Preview2 +@Composable +private fun DualPodsCardCriticalPreview() = PreviewWrapper { + // Case at 10%: the whole band stays under a full charge. + DualPodsCard( + device = MockPodDataProvider.dualPodBatteries(left = 0.10f, right = 0.05f, case = 0.10f), + showDebug = false, + now = SystemTimeSource.now(), + ) +} + +@Preview2 +@Composable +private fun DualPodsCardCaseChargesUncertainPreview() = PreviewWrapper { + // Case at 20%: the decile band straddles a full charge, so the line makes no claim. + DualPodsCard( + device = MockPodDataProvider.dualPodBatteries(case = 0.20f), + showDebug = false, + now = SystemTimeSource.now(), + ) +} + +@Preview2 +@Composable +private fun DualPodsCardCaseWithoutSpecPreview() = PreviewWrapper { + // PowerBeats Pro has a case, but Apple publishes no figures for it, so there is no line. + DualPodsCard( + device = MockPodDataProvider.dualPodBatteries( + case = 0.60f, + model = PodModel.POWERBEATS_PRO, + label = "PowerBeats Pro", + ), + showDebug = false, + now = SystemTimeSource.now(), + ) +} + @Preview2 @Composable private fun DualPodsCardMinimalPreview() = PreviewWrapper { 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 2082a444..2d974503 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 @@ -375,6 +375,26 @@ private fun SinglePodsCardEstimatePreview() = PreviewWrapper { ) } +@Preview2 +@Composable +private fun SinglePodsCardWarnPreview() = PreviewWrapper { + SinglePodsCard( + device = MockPodDataProvider.singlePodBattery(percent = 0.20f), + showDebug = false, + now = SystemTimeSource.now(), + ) +} + +@Preview2 +@Composable +private fun SinglePodsCardCriticalPreview() = PreviewWrapper { + SinglePodsCard( + device = MockPodDataProvider.singlePodBattery(percent = 0.08f), + showDebug = false, + now = SystemTimeSource.now(), + ) +} + @Preview2 @Composable private fun SinglePodsCardMinimalPreview() = PreviewWrapper { diff --git a/app/src/main/java/eu/darken/capod/monitor/core/battery/CaseCharges.kt b/app/src/main/java/eu/darken/capod/monitor/core/battery/CaseCharges.kt new file mode 100644 index 00000000..cbc8ece7 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/monitor/core/battery/CaseCharges.kt @@ -0,0 +1,60 @@ +package eu.darken.capod.monitor.core.battery + +import eu.darken.capod.monitor.core.BatteryReading +import eu.darken.capod.pods.core.apple.PodModel +import kotlin.math.floor + +/** How many more times the case can recharge both earbuds, and how firm that number is. */ +data class CaseCharges( + val count: Int, + val display: Display, + val adequacy: Adequacy, +) { + enum class Display { + /** Nothing left to give — distinct from [LESS_THAN_ONE], which still implies some charge. */ + EMPTY, + LESS_THAN_ONE, + AT_LEAST, + APPROXIMATE, + } + + enum class Adequacy { ENOUGH, NOT_ENOUGH, UNCERTAIN } +} + +/** + * Reads a truncating [reading] as the interval `[percent, percent + resolution)` and scales it by + * the spec, so a decile source at 20% asks "is 4.0 x 0.20 .. 4.0 x 0.30 above one charge?" rather + * than pretending 0.8 is exact. Only an interval that lies wholly on one side of a full charge + * makes a claim; anything straddling it is [CaseCharges.Adequacy.UNCERTAIN]. That also absorbs the + * bounce between adjacent readings, so no state has to be remembered between frames. + * + * A spec that is itself a lower bound has no upper end, so it can never claim "not enough". + * + * Null when the model publishes no case figures or the reading is missing or nonsense — the caller + * shows no line at all rather than a neutral one. + */ +fun caseCharges(spec: PodModel.CaseSpec?, reading: BatteryReading?): CaseCharges? { + if (spec == null || !spec.fullPairRecharges.isFinite() || spec.fullPairRecharges <= 0f) return null + if (reading == null || !reading.percent.isFinite() || reading.percent < 0f) return null + + val resolution = if (reading.resolution.isFinite() && reading.resolution > 0f) reading.resolution else 0f + val lowest = spec.fullPairRecharges * reading.percent + val highest = spec.fullPairRecharges * (reading.percent + resolution) + + val adequacy = when { + lowest >= 1f -> CaseCharges.Adequacy.ENOUGH + spec.isLowerBound -> CaseCharges.Adequacy.UNCERTAIN + highest < 1f -> CaseCharges.Adequacy.NOT_ENOUGH + else -> CaseCharges.Adequacy.UNCERTAIN + } + + val count = floor(lowest).toInt() + val display = when { + reading.percent == 0f -> CaseCharges.Display.EMPTY + count < 1 -> CaseCharges.Display.LESS_THAN_ONE + spec.isLowerBound -> CaseCharges.Display.AT_LEAST + else -> CaseCharges.Display.APPROXIMATE + } + + return CaseCharges(count = count, display = display, adequacy = adequacy) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c7318939..87112615 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -276,6 +276,20 @@ Battery unavailable Battery low Battery critically low + + + ~%d more full charge for both earbuds + ~%d more full charges for both earbuds + + + At least %d more full charge for both earbuds + At least %d more full charges for both earbuds + + Less than one more full charge for both earbuds + Case is empty, no charges left for your earbuds + Enough for a full charge + Not enough for a full charge + May not be enough for a full charge Open Closed Not connected to a device diff --git a/app/src/test/java/eu/darken/capod/monitor/core/battery/CaseChargesTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/battery/CaseChargesTest.kt new file mode 100644 index 00000000..4a2118e6 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/monitor/core/battery/CaseChargesTest.kt @@ -0,0 +1,110 @@ +package eu.darken.capod.monitor.core.battery + +import eu.darken.capod.monitor.core.BatteryReading +import eu.darken.capod.pods.core.apple.PodModel +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class CaseChargesTest : BaseTest() { + + private val exact = PodModel.CaseSpec(fullPairRecharges = 4.0f) + private val lowerBound = PodModel.CaseSpec(fullPairRecharges = 3.8f, isLowerBound = true) + + private val decile = 0.10f + private val percent = 0.01f + + private fun charges(spec: PodModel.CaseSpec?, percent: Float, resolution: Float) = + caseCharges(spec, BatteryReading(percent, resolution)) + + @Test + fun `no spec means no line`() { + caseCharges(null, BatteryReading(0.5f, percent)).shouldBeNull() + } + + @Test + fun `no reading means no line`() { + caseCharges(exact, null).shouldBeNull() + } + + @Test + fun `an unusable spec is treated as absent`() { + charges(PodModel.CaseSpec(fullPairRecharges = 0f), 0.5f, percent).shouldBeNull() + charges(PodModel.CaseSpec(fullPairRecharges = -1f), 0.5f, percent).shouldBeNull() + charges(PodModel.CaseSpec(fullPairRecharges = Float.NaN), 0.5f, percent).shouldBeNull() + } + + @Test + fun `an unusable reading is treated as absent`() { + charges(exact, -1f, percent).shouldBeNull() + charges(exact, Float.NaN, percent).shouldBeNull() + charges(exact, Float.POSITIVE_INFINITY, percent).shouldBeNull() + } + + @Test + fun `the count comes from the spec times the reading`() { + charges(exact, 1.0f, percent)!!.count shouldBe 4 + charges(exact, 0.75f, percent)!!.count shouldBe 3 + charges(exact, 0.50f, percent)!!.count shouldBe 2 + charges(lowerBound, 1.0f, percent)!!.count shouldBe 3 + } + + @Test + fun `a whole interval above one charge is enough`() { + // 4.0 x 0.25 = 1.0 exactly, so even the low end clears a full charge + charges(exact, 0.25f, decile)!!.adequacy shouldBe CaseCharges.Adequacy.ENOUGH + charges(exact, 0.60f, percent)!!.adequacy shouldBe CaseCharges.Adequacy.ENOUGH + } + + @Test + fun `a whole interval below one charge is not enough`() { + // 4.0 x (0.10 + 0.10) = 0.8, still short of a full charge at the top of the interval + charges(exact, 0.10f, decile)!!.adequacy shouldBe CaseCharges.Adequacy.NOT_ENOUGH + charges(exact, 0.20f, percent)!!.adequacy shouldBe CaseCharges.Adequacy.NOT_ENOUGH + } + + @Test + fun `an interval straddling one charge claims nothing`() { + // 4.0 x 0.20 = 0.8, 4.0 x 0.30 = 1.2 + charges(exact, 0.20f, decile)!!.adequacy shouldBe CaseCharges.Adequacy.UNCERTAIN + // the same reading at a finer resolution does resolve + charges(exact, 0.20f, percent)!!.adequacy shouldBe CaseCharges.Adequacy.NOT_ENOUGH + } + + @Test + fun `a lower bound spec never claims not enough`() { + // 3.8 x 0.25 = 0.95 — a confident "not enough" for a case that may well hold more + charges(lowerBound, 0.25f, percent)!!.adequacy shouldBe CaseCharges.Adequacy.UNCERTAIN + charges(lowerBound, 0.01f, percent)!!.adequacy shouldBe CaseCharges.Adequacy.UNCERTAIN + charges(lowerBound, 0.10f, decile)!!.adequacy shouldBe CaseCharges.Adequacy.UNCERTAIN + } + + @Test + fun `a lower bound spec can still claim enough`() { + charges(lowerBound, 0.30f, percent)!!.adequacy shouldBe CaseCharges.Adequacy.ENOUGH + } + + @Test + fun `an exact spec renders as an approximation`() { + charges(exact, 0.50f, percent)!!.display shouldBe CaseCharges.Display.APPROXIMATE + } + + @Test + fun `a lower bound spec renders as a floor`() { + charges(lowerBound, 0.50f, percent)!!.display shouldBe CaseCharges.Display.AT_LEAST + } + + @Test + fun `below a full charge renders as less than one`() { + charges(exact, 0.20f, percent)!!.display shouldBe CaseCharges.Display.LESS_THAN_ONE + charges(lowerBound, 0.20f, percent)!!.display shouldBe CaseCharges.Display.LESS_THAN_ONE + charges(exact, 0.01f, percent)!!.display shouldBe CaseCharges.Display.LESS_THAN_ONE + } + + @Test + fun `an empty case renders as empty`() { + charges(exact, 0f, percent)!!.display shouldBe CaseCharges.Display.EMPTY + charges(lowerBound, 0f, decile)!!.display shouldBe CaseCharges.Display.EMPTY + } +}