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