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 " . <value>" 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.
This commit is contained in:
darken
2026-09-02 22:05:16 +02:00
committed by Matthias Urhahn
parent e68927595c
commit d8a6ef2286
5 changed files with 95 additions and 3 deletions
@@ -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
@@ -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)}"
}
}
@@ -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" />
<LinearLayout
android:layout_width="36dp"
+4
View File
@@ -292,6 +292,10 @@
<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>
<!-- Compact form of the same count for the expanded notification's case row, e.g. "0.8 charges". The number is pre-formatted with one decimal, so it is plural even at "1.0". -->
<string name="battery_case_charges_short">%1$s charges</string>
<!-- Same, for models whose published case figure is only a lower bound, e.g. "1.6+ charges". -->
<string name="battery_case_charges_short_at_least">%1$s+ charges</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>
@@ -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)
}
}