diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt
index 7d4cc079..0fc6d07a 100644
--- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt
+++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt
@@ -513,6 +513,7 @@ private fun CaseChargesLine(
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.UNCERTAIN -> stringResource(R.string.battery_case_charges_uncertain)
CaseCharges.Display.AT_LEAST -> pluralStringResource(
R.plurals.battery_case_charges_at_least,
charges.count,
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 95f0822b..b272dcfe 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
@@ -14,6 +14,9 @@ data class CaseCharges(
/** Nothing left to give — distinct from [LESS_THAN_ONE], which still implies some charge. */
EMPTY,
LESS_THAN_ONE,
+
+ /** No number at all: an open-ended interval that has not yet reached a full charge. */
+ UNCERTAIN,
AT_LEAST,
APPROXIMATE,
}
@@ -32,6 +35,10 @@ data class CaseCharges(
*
* A spec that is itself a lower bound has no upper end, so it can never claim "not enough".
*
+ * The wording follows the whole interval, not its pessimistic end: an uncertain one rounds to a
+ * single charge, or names no number at all ([CaseCharges.Display.UNCERTAIN]) when the spec is a
+ * lower bound and there is no upper end to round from.
+ *
* 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.
*/
@@ -50,13 +57,23 @@ fun caseCharges(spec: PodModel.CaseSpec?, reading: BatteryReading?): CaseCharges
else -> CaseCharges.Adequacy.UNCERTAIN
}
- val count = floor(lowest).toInt()
+ val floored = floor(lowest).toInt()
+
val display = when {
reading.percent == 0f -> CaseCharges.Display.EMPTY
- count < 1 -> CaseCharges.Display.LESS_THAN_ONE
+ adequacy == CaseCharges.Adequacy.NOT_ENOUGH -> CaseCharges.Display.LESS_THAN_ONE
+ adequacy == CaseCharges.Adequacy.UNCERTAIN && spec.isLowerBound -> CaseCharges.Display.UNCERTAIN
+ adequacy == CaseCharges.Adequacy.UNCERTAIN -> CaseCharges.Display.APPROXIMATE
spec.isLowerBound -> CaseCharges.Display.AT_LEAST
else -> CaseCharges.Display.APPROXIMATE
}
+ val count = when {
+ reading.percent == 0f -> 0
+ adequacy == CaseCharges.Adequacy.NOT_ENOUGH -> 0
+ adequacy == CaseCharges.Adequacy.UNCERTAIN && !spec.isLowerBound -> 1
+ else -> floored
+ }
+
return CaseCharges(count = count, display = display, adequacy = adequacy)
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 87112615..d0c8a415 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -287,6 +287,8 @@
Less than one more full charge for both earbuds
Case is empty, no charges left for your earbuds
+
+ Not sure how many full charges are left for both earbuds
Enough for a full charge
Not enough for a full charge
May not be enough for a full charge
diff --git a/app/src/test/java/eu/darken/capod/main/ui/overview/cards/CaseChargesLineTest.kt b/app/src/test/java/eu/darken/capod/main/ui/overview/cards/CaseChargesLineTest.kt
index e0df3396..ce8dd8c7 100644
--- a/app/src/test/java/eu/darken/capod/main/ui/overview/cards/CaseChargesLineTest.kt
+++ b/app/src/test/java/eu/darken/capod/main/ui/overview/cards/CaseChargesLineTest.kt
@@ -92,11 +92,19 @@ class CaseChargesLineTest : BaseComposeRobolectricTest() {
}
@Test
- fun `a reading astride a full charge claims nothing`() {
+ fun `a reading astride a full charge rounds to one`() {
setCard(device(case = 0.20f))
+ assertAdequacy(charges(1), R.string.battery_case_charges_state_uncertain_cd)
+ }
+
+ @Test
+ fun `an open ended spec below a full charge names no number`() {
+ // AirPods (Gen 1) publish a lower bound, so 3.8 x 0.10 has no upper end to round to
+ setCard(device(case = 0.10f, model = PodModel.AIRPODS_GEN1))
+
assertAdequacy(
- context.getString(R.string.battery_case_charges_less_than_one),
+ context.getString(R.string.battery_case_charges_uncertain),
R.string.battery_case_charges_state_uncertain_cd,
)
}
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 1a763afe..4b305f26 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
@@ -107,10 +107,36 @@ class CaseChargesTest : BaseTest() {
@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 interval straddling one charge rounds to one instead of claiming less`() {
+ // 4.0 x 0.20 = 0.8, 4.0 x 0.30 = 1.2 — the text must not deny what the interval allows
+ val straddle = charges(exact, 0.20f, decile)!!
+ straddle.display shouldBe CaseCharges.Display.APPROXIMATE
+ straddle.count shouldBe 1
+ }
+
+ @Test
+ fun `an uncertain lower bound spec names no number`() {
+ // 3.8 x 0.10 = 0.38 with no upper end, so neither "less than one" nor "~1" is supportable
+ val vague = charges(lowerBound, 0.10f, decile)!!
+ vague.adequacy shouldBe CaseCharges.Adequacy.UNCERTAIN
+ vague.display shouldBe CaseCharges.Display.UNCERTAIN
+
+ charges(lowerBound, 0.20f, percent)!!.display shouldBe CaseCharges.Display.UNCERTAIN
+ }
+
+ @Test
+ fun `a lower bound spec that reaches enough still counts`() {
+ val enough = charges(lowerBound, 0.50f, decile)!!
+ enough.adequacy shouldBe CaseCharges.Adequacy.ENOUGH
+ enough.display shouldBe CaseCharges.Display.AT_LEAST
+ // 3.8 x 0.50 = 1.9
+ enough.count shouldBe 1
+ }
+
@Test
fun `an empty case renders as empty`() {
charges(exact, 0f, percent)!!.display shouldBe CaseCharges.Display.EMPTY