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