Skip to content

General: Colour low batteries and show remaining case charges - #694

Merged
d4rken merged 11 commits into
mainfrom
feat/battery-color-semantics
Aug 25, 2026
Merged

General: Colour low batteries and show remaining case charges#694
d4rken merged 11 commits into
mainfrom
feat/battery-color-semantics

Conversation

@d4rken

@d4rken d4rken commented Aug 25, 2026

Copy link
Copy Markdown
Member

What changed

The battery percentage now turns orange when a pod runs low and red when it gets critical, instead of looking the same at every level. Screen readers announce those states too, so the warning is not carried by colour alone.

Under the case percentage there is a new line saying how many more times the case can fully recharge both earbuds, for example "~2 more full charges for both earbuds". It shows green while the case can still manage at least one full recharge and orange once it cannot. When the reading is too coarse to be sure either way, the line says so instead of guessing, and models Apple publishes no case figures for show no line at all.

This also fixes the amber theme, which was drawing low-battery warnings in olive and healthy batteries in orange, so a full battery looked like a warning and a warning did not.

Technical Context

  • Battery colours cannot come from the theme palette. With Material You plus three bundled themes, primary and tertiary have no stable hue (amber's tertiary is olive, its primary is burnt orange), so the warning and positive tones are fixed values, picked against a test covering all 18 static palette/contrast/mode combinations and measured against the real composited backgrounds rather than raw surface. Critical keeps error and healthy keeps primary, both deliberately. Consolidating four duplicated colour blocks changed behaviour in two of them, which previously rendered a non-finite battery value as a full healthy ring.
  • The light/dark variant resolves from the app's own theme state rather than isSystemInDarkTheme(). An in-app dark override on a light system would otherwise select the light tones and fail the contrast the tokens were chosen for.
  • The case figure is Apple's published total-listening-with-case divided by single-charge listening, minus one, with both figures taken under the same noise-control condition. Because Bluetooth can report battery in 10% steps, a reading is treated as an interval and only claims "enough" or "not enough" when the whole interval agrees; that is why the case reading now carries the step size of whichever source produced it.
  • AirPods Gen 1 and Gen 2 publish capacity as a floor ("more than 24 hours"), so they are marked as lower bounds and never claim "not enough" — except at a zero reading, where the empty message already commits to that. AirPods Pro (1st generation) carries no figure at all, because its with-case total is not on Apple's current pages and an unsourced value was not worth the risk.
  • The committed en-US Play Store screenshots are not regenerated here and still show a dashboard without the new charges line. They were regenerated during this work, but against the six-locale policy that chore(screenshots): Commit only English Play Store screenshots replaced while this branch was open, so that commit was dropped rather than resurrect the deleted locales. A regeneration with the new script is still owed.

d4rken added 11 commits August 25, 2026 21:52
The overview duplicates the same 30%/15% thresholds in four places, with two
subtly different unknown checks: the capsule and the pod gauge accept any
percent >= 0f, so Float.POSITIVE_INFINITY renders as a full, healthy ring.
batteryTier() settles that on the finite check the other two already use.
The warn band was drawn in colorScheme.tertiary, which is whatever the palette
seed produces: olive under the amber theme, where the healthy band (primary) is
burnt orange, so a warning read as decoration and healthy read as a warning.

The warn band now uses fixed light/dark tokens, and the percentage text is
tinted at warn and critical instead of the level living in the gauge alone.
Values are derived against the composited backgrounds the cards actually draw
on: the gauge Surface at 4dp tonal elevation, and the same surface at alpha 0.7
that a card without live data uses. Measuring against raw surface would have
admitted values below 3:1 on screen.

The tokens are resolved from the theme mode CapodTheme already computed, not
from isSystemInDarkTheme(), so an in-app dark override on a light system does
not pick the light tokens.
Crossing the warn or critical threshold was expressed as a colour change only,
so it did not exist for a screen reader. Each battery slot on the overview card
now carries the level as a state description.
Derived per model as hoursWithCase / hoursListening - 1, with both figures read
under the same noise control condition Apple quotes them under, matching their
stated test method (drained AirPods recharged to 100% and playback resumed
until both the AirPods and the case were fully discharged).

Models whose published total reads "more than N hours" are marked as a lower
bound so nothing downstream can turn an unbounded figure into a confident
claim. AirPods Pro is deliberately left without a spec: its 4.5 h ANC listening
figure is verified but the matching with-case total is not, and an unsourced
number is worse than no line at all.

The spec gates on itself rather than on hasCase — twenty models set hasCase,
including Beats Solo Buds, whose case holds no battery.
PodDevice.batteryCase merges an AAP notification, the encrypted advertisement
payload, the public advertisement nibble and the cache into one Float, and the
provenance is gone by the time anyone reads it. The public nibble only carries
deciles, so 20% there can mean anything up to 29%.

batteryCaseReading walks the same precedence and keeps the step size of the
source that won, leaving batteryCase untouched for every existing caller.
The case percentage alone doesn't answer the question people actually have,
which is whether the case can top the earbuds up again. The card now says so in
words, below the case row.

The reading is treated as the interval it really is: a decile source at 20% on
a 4.0-charge case means 0.8 to 1.2 charges, which is not an answer, so the line
stays neutral instead of flipping colour as the reading bounces between
adjacent frames. Only an interval that lies wholly above or below one full
charge is coloured, which removes the need to remember anything between frames.

The line is stacked rather than inline: the case row already has four children
with only the capsule weighted, so a plural in front of it would collapse the
capsule at large font scales and in locales with long plural forms.
Colour is not exposed through the semantics tree, and captureToImage() times
out under Robolectric, so the rendered result is pinned in two halves: which
tier each of the five slots reports, and which colour a tier resolves to per
theme mode. The theme test drives an in-app dark override on a light host,
which is the case a plain isSystemInDarkTheme() read would get wrong.

The case charges line is covered for presence, the three adequacy states, both
special renderings, and under RTL, a narrow card and a doubled font scale.
The reading is an interval that excludes its upper end, so an upper end
landing exactly on one full charge still leaves every reachable value
below one. AirPods Gen 4 at 10% and Pro 3 at 40% read neutral instead of
orange because of it.

Fixes review finding F1
The visible count came from the pessimistic end of the interval alone, so
an interval straddling a full charge said "Less than one more full charge"
while the colour stayed neutral and TalkBack said "may not be enough". An
uncertain interval now rounds to a single charge instead, and an
open-ended spec that has not reached one charge names no number at all.

Fixes review finding F2
AirPods Gen 1 and Gen 2 publish their case capacity as a lower bound, and
the adequacy check let that guard run before any zero-reading check. At a
0% case those two models rendered the definite "no charges left" text in
the neutral uncertainty colour while the screen reader hedged with "may
not be enough", contradicting the same node three ways.

The empty reading is now handled before the lower-bound guard. It sits
after the "enough" branch, which a zero reading can never satisfy, so it
cannot mask a positive claim. The lower-bound rule exists to avoid
underselling a case whose published capacity is only a floor, and an
empty case has nothing to undersell.

Fixes review finding F4
@d4rken d4rken added bug Something isn't working enhancement Add a new feature of improve an existing feature labels Aug 25, 2026
@d4rken
d4rken merged commit 2356726 into main Aug 25, 2026
11 checks passed
@d4rken
d4rken deleted the feat/battery-color-semantics branch August 25, 2026 20:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working enhancement Add a new feature of improve an existing feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant