General: Colour low batteries and show remaining case charges - #694
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
primaryandtertiaryhave no stable hue (amber'stertiaryis olive, itsprimaryis 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 rawsurface. Critical keepserrorand healthy keepsprimary, 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.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.chore(screenshots): Commit only English Play Store screenshotsreplaced 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.