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.
This commit is contained in:
darken
2026-08-25 22:33:42 +02:00
committed by Matthias Urhahn
parent b10b42eede
commit 11d6ddf3fa
6 changed files with 415 additions and 54 deletions
@@ -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",
@@ -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 {
@@ -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 {
@@ -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)
}
+14
View File
@@ -276,6 +276,20 @@
<string name="battery_unavailable_label">Battery unavailable</string>
<string name="battery_state_low_cd">Battery low</string>
<string name="battery_state_critical_cd">Battery critically low</string>
<!-- Case charges: how often the case can still fully recharge BOTH earbuds. Not charge cycles, and not a statement that anything is charging right now. -->
<plurals name="battery_case_charges_approx">
<item quantity="one">~%d more full charge for both earbuds</item>
<item quantity="other">~%d more full charges for both earbuds</item>
</plurals>
<plurals name="battery_case_charges_at_least">
<item quantity="one">At least %d more full charge for both earbuds</item>
<item quantity="other">At least %d more full charges for both earbuds</item>
</plurals>
<string name="battery_case_charges_less_than_one">Less than one more full charge for both earbuds</string>
<string name="battery_case_charges_empty">Case is empty, no charges left for your earbuds</string>
<string name="battery_case_charges_state_enough_cd">Enough for a full charge</string>
<string name="battery_case_charges_state_not_enough_cd">Not enough for a full charge</string>
<string name="battery_case_charges_state_uncertain_cd">May not be enough for a full charge</string>
<string name="pods_case_status_open_label">Open</string>
<string name="pods_case_status_closed_label">Closed</string>
<string name="pods_connection_state_disconnected_label">Not connected to a device</string>
@@ -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
}
}