mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user