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 + } +}