From d8a6ef2286b5514d23e912b3c54cbf72c44c094d Mon Sep 17 00:00:00 2001 From: darken Date: Wed, 2 Sep 2026 18:53:43 +0200 Subject: [PATCH] feat(monitor): Show remaining case charges in the notification The case row in the expanded notification carried a percentage alone, which doesn't answer whether the case can still top the earbuds up. It now appends the count the overview card already computes, in the same " . " shape the earbud rows use for their time estimates. The figure is shown as a fraction rather than the card's integer because the integer barely moves on a small case: AirPods Pro 3 publishes a 2.0 charge case, so the count only ever reads 0, 1 or 2, and everything below 50% reads 0. A notification suffix makes no adequacy claim, so it can report the number without the interval logic the coloured card line needs. The decimal is cut rather than rounded. A 4.0 case at 24% covers [0.96, 1.00), which the card calls short of a full charge, and rounding to the nearest tenth would print "1.0" beside that. Cutting also keeps the lower-bound wording honest, since 3.8 at 20% is 0.76 and may not claim "0.8+". The epsilon before the cut absorbs binary float error only: 2.0 at 35% lands on 0.69999999 and still has to read 0.7. An empty case drops the "+" even on a lower-bound spec, matching the card's treatment of zero as exact rather than as an underestimate. Apple's Optimized Charge Limit is deliberately not modelled. It pauses around 80% only when the routine predicts you won't need the pods yet, so it applies to some top-ups and not others, and the unit stays whole pair charges. The case label gets a width cap because the row's progress bar is its only weighted child. The added word is translatable, and a long rendering at a large font scale would otherwise take the row and leave the bar at zero width. --- .../capod/monitor/core/battery/CaseCharges.kt | 12 ++++- .../ui/MonitorNotificationViewFactory.kt | 28 +++++++++- .../monitor_notification_dual_pods_big.xml | 3 +- app/src/main/res/values/strings.xml | 4 ++ .../monitor/core/battery/CaseChargesTest.kt | 51 +++++++++++++++++++ 5 files changed, 95 insertions(+), 3 deletions(-) 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 index c14e2098..d882d884 100644 --- 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 @@ -7,6 +7,8 @@ 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, + /** The low end of the reading's interval, unrounded — what [count] and [display] are derived from. */ + val fraction: Float, val display: Display, val adequacy: Adequacy, ) { @@ -77,5 +79,13 @@ fun caseCharges(spec: PodModel.CaseSpec?, reading: BatteryReading?): CaseCharges else -> floored } - return CaseCharges(count = count, display = display, adequacy = adequacy) + return CaseCharges(count = count, fraction = lowest, display = display, adequacy = adequacy) } + +/** + * [CaseCharges.fraction] cut to one decimal instead of rounded, so a case the card calls short of a + * full charge can never read "1.0". The epsilon absorbs binary float error only: a 2.0 spec at 35% + * lands on 0.69999999 and still has to read 0.7. + */ +val CaseCharges.displayFraction: Float + get() = floor(fraction * 10f + 1e-4f) / 10f diff --git a/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotificationViewFactory.kt b/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotificationViewFactory.kt index d535c334..7f2f74ad 100644 --- a/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotificationViewFactory.kt +++ b/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotificationViewFactory.kt @@ -7,12 +7,17 @@ import dagger.hilt.android.qualifiers.ApplicationContext import eu.darken.capod.R import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.monitor.core.battery.BatteryEstimate +import eu.darken.capod.monitor.core.battery.CaseCharges +import eu.darken.capod.monitor.core.battery.caseCharges +import eu.darken.capod.monitor.core.battery.displayFraction import eu.darken.capod.monitor.core.battery.displayMinutes +import eu.darken.capod.monitor.core.batteryCaseReading import eu.darken.capod.pods.core.apple.PodModel 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.getBatteryDrawable import eu.darken.capod.pods.core.apple.ble.isKnownBattery +import java.util.Locale import javax.inject.Inject import kotlin.math.roundToInt @@ -114,7 +119,10 @@ class MonitorNotificationViewFactory @Inject constructor( setImageViewResource(R.id.pod_case_icon, device.caseIcon) val casePercent = device.batteryCase setProgressBar(R.id.pod_case_progress, 100, percentToInt(casePercent), false) - setTextViewText(R.id.pod_case_label, formatBatteryPercent(context, casePercent)) + setTextViewText( + R.id.pod_case_label, + formatBatteryPercent(context, casePercent) + caseChargesSuffix(device) + ) setViewVisibility(R.id.pod_case_charging, if (device.isCaseCharging == true) View.VISIBLE else View.GONE) } @@ -168,4 +176,22 @@ class MonitorNotificationViewFactory @Inject constructor( private fun estimateSuffix(pod: BatteryEstimate.Pod?, charging: Boolean): String = pod?.displayMinutes(charging)?.let { " · ${formatBatteryDurationShort(context, it)}" } ?: "" + /** + * How many more full pair charges the case holds, e.g. " · 0.8 charges". Empty when the model + * publishes no case figure, so the row keeps showing the percentage alone. + */ + private fun caseChargesSuffix(device: PodDevice): String { + val spec = device.model.caseSpec ?: return "" + val charges = caseCharges(spec, device.batteryCaseReading) ?: return "" + val amount = String.format(Locale.getDefault(), "%.1f", charges.displayFraction) + // An empty case has nothing for an open ended spec to undersell, so it drops the "+". + val resId = + if (spec.isLowerBound && charges.display != CaseCharges.Display.EMPTY) { + R.string.battery_case_charges_short_at_least + } else { + R.string.battery_case_charges_short + } + return " · ${context.getString(resId, amount)}" + } + } diff --git a/app/src/main/res/layout/monitor_notification_dual_pods_big.xml b/app/src/main/res/layout/monitor_notification_dual_pods_big.xml index f25ead87..ac8b7050 100644 --- a/app/src/main/res/layout/monitor_notification_dual_pods_big.xml +++ b/app/src/main/res/layout/monitor_notification_dual_pods_big.xml @@ -109,7 +109,8 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="8dp" - tools:text="88%" /> + android:maxWidth="144dp" + tools:text="88% · 0.8 charges" /> Enough for a full charge Not enough for a full charge May not be enough for a full charge + + %1$s charges + + %1$s+ charges 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 index 1422429d..56ad1b03 100644 --- 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 @@ -2,6 +2,7 @@ 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.floats.plusOrMinus import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.shouldBe import org.junit.jupiter.api.Test @@ -148,4 +149,54 @@ class CaseChargesTest : BaseTest() { floored.display shouldBe CaseCharges.Display.EMPTY floored.adequacy shouldBe CaseCharges.Adequacy.NOT_ENOUGH } + + @Test + fun `the fraction is the count before flooring`() { + // AirPods Pro 3 ships a 2.0 case, so 42% is short of a single pair charge + val pro3 = PodModel.CaseSpec(fullPairRecharges = 2.0f) + charges(pro3, 0.42f, percent)!!.fraction shouldBe (0.84f plusOrMinus 0.001f) + charges(exact, 0.42f, percent)!!.fraction shouldBe (1.68f plusOrMinus 0.001f) + charges(lowerBound, 1.0f, percent)!!.fraction shouldBe (3.8f plusOrMinus 0.001f) + } + + @Test + fun `the fraction takes the low end of a coarse reading`() { + // 4.0 x 0.40 = 1.6, not the 2.0 the decile interval also permits + charges(exact, 0.40f, decile)!!.fraction shouldBe (1.6f plusOrMinus 0.001f) + } + + @Test + fun `the fraction of an empty case is zero`() { + charges(exact, 0f, percent)!!.fraction shouldBe (0f plusOrMinus 0.001f) + } + + @Test + fun `the shown decimal never reads as a charge the card denies`() { + // 4.0 x 0.24 = 0.96 — to the nearest tenth that prints "1.0" under a "less than one" card + val short = charges(exact, 0.24f, percent)!! + short.display shouldBe CaseCharges.Display.LESS_THAN_ONE + short.displayFraction shouldBe (0.9f plusOrMinus 0.001f) + + val pro3 = charges(PodModel.CaseSpec(fullPairRecharges = 2.0f), 0.48f, percent)!! + pro3.display shouldBe CaseCharges.Display.LESS_THAN_ONE + pro3.displayFraction shouldBe (0.9f plusOrMinus 0.001f) + } + + @Test + fun `the shown decimal survives float representation error`() { + // 2.0 x 0.35 lands on 0.69999999, which must not cut down to 0.6 + val spec = PodModel.CaseSpec(fullPairRecharges = 2.0f) + charges(spec, 0.35f, percent)!!.displayFraction shouldBe (0.7f plusOrMinus 0.001f) + } + + @Test + fun `a lower bound spec keeps a floor it can honour`() { + // 3.8 x 0.20 = 0.76, so "0.8+" would promise more than the reading guarantees + charges(lowerBound, 0.20f, percent)!!.displayFraction shouldBe (0.7f plusOrMinus 0.001f) + } + + @Test + fun `a whole charge still shows as one`() { + charges(exact, 0.25f, percent)!!.displayFraction shouldBe (1.0f plusOrMinus 0.001f) + } }