The map's tag cards currently spell the battery out in words:
Nearby (●●●○○) · Battery medium
An icon says the same thing in a fraction of the width, and that row is competing for space with the tag name, the signal dots and the time. This is a self-contained front-end change — no Apple protocol, no account, no network.
What to change
NearbyTagLabel.shortBatteryLabel returns a string resource per level. The four levels come from FindMyAdvertisement.BatteryLevel:
| Level |
Meaning |
FULL |
|
MEDIUM |
|
LOW |
|
VERY_LOW |
critically low |
Four states, and exactly four. There is no "unknown" on this path — batteryLevelOf defaults to FULL, so every sighting has one of these.
The text is assembled in MapsActivity at the nearby_now_with_battery_and_signal / nearby_now_with_battery strings, so the icon needs to go into that row rather than into the string.
Where the icons come from
This project already uses Material Symbols and they are Apache 2.0, so nothing new needs adding. Every icon in app/src/main/res/drawable/ is an exported vector with viewportWidth="960" — that is the Material Symbols export, and history_24px.xml is a good one to copy the shape of, including the ?attr/colorControlNormal tint that makes it follow the theme.
Please do not pull in an icon dependency for four glyphs.
Things that will trip you up
- An icon is not an accessibility substitute for words. Whatever replaces the text needs a
contentDescription, so battery_short_full and friends probably survive as descriptions rather than being deleted. A screen reader user should still hear "battery medium".
- Strings are edited through a script, never by hand. Ten locales;
python scripts/add_strings.py prints the usage, and --check runs in CI. Hand-editing ten files is how one locale silently keeps the old wording.
- Contrast is a number here. A tinted icon on the card background should clear 3:1 as a non-text element. There is prior art: a selected map pin once shipped at 1.23:1 — present, correct, invisible.
- Dark mode is a second render, not an afterthought. About half of what breaks in this app breaks in only one mode.
- It wants a test, and the cheap kind is enough.
SystemColorsLayoutTest is the pattern: build a themed context, inflate, assert. What is worth asserting is that each of the four levels resolves to a different drawable and that the drawable actually loads — a battery icon that silently renders as nothing is the exact failure the history timeline shipped with once. CONTRIBUTING.md has the how.
What the pull request needs to show
This is a visual change, so the review is visual. Two things, and neither needs a phone:
1. Screenshots — all four states, both themes. Eight images, or four if you put the states side by side. Before and after, so the space saved is visible rather than asserted.
You do not need a device or an emulator of your own. .claude/skills/device-screenshots/ documents the pattern the repo already uses: inflate against a themed context, draw to a bitmap, and write it to the directory the test runner hands you, which comes back to the host. SystemColorsLayoutTest is a worked example, and createConfigurationContext with UI_MODE_NIGHT_YES is how dark mode is rendered without touching a device setting.
2. Tests, and a note on what you checked. The bar is not high but it is specific:
- each of the four levels resolves to a different drawable, and each drawable actually loads the way the app loads it
- the row still measures sensibly — an icon that measures to zero is invisible and green
- the
contentDescription is set for every state
- the contrast figure for the tinted icon against the card, in both themes
Say what you ran and what you did not. A pull request here is expected to state plainly what was verified and what was assumed — an honest gap is worth more than an implied "it works". If you cannot run the instrumented suite, say so and describe what you checked instead; that is a normal answer and not a blocker.
One habit worth borrowing: break the thing on purpose and confirm the test goes red. Four tests passing first try is a reason for suspicion rather than confidence.
Optional second half: the history list
The per-tag history rows show the raw status byte:
acc 65 m · conf 0 · status 144 = 0x90 = 0b10010000
Bits 6-7 of that byte are the battery, and they are now confirmed against real hardware — two AirTags watched across a battery change reported 0x90 (low) on months-old cells, then 0x10 (full) and 0x50 (medium) once replaced, with every other bit unchanged.
But that panel deliberately shows the number and no interpretation, and a comment in LocationReportFields records the reason: it is debug metadata, the raw byte is certainly right, and a label beside it would be the app's opinion competing with the tag's on a screen meant for evidence.
So treat this half as not yet decided. If you want to do it, say so on this issue first and let it be settled — and whoever does it has to update that comment in the same change, or the code and the file arguing against it will contradict each other.
The first half needs no such discussion. Take it on its own if you prefer.
Interactively co-authored by Claude Code and @parawanderer
The map's tag cards currently spell the battery out in words:
An icon says the same thing in a fraction of the width, and that row is competing for space with the tag name, the signal dots and the time. This is a self-contained front-end change — no Apple protocol, no account, no network.
What to change
NearbyTagLabel.shortBatteryLabelreturns a string resource per level. The four levels come fromFindMyAdvertisement.BatteryLevel:FULLMEDIUMLOWVERY_LOWFour states, and exactly four. There is no "unknown" on this path —
batteryLevelOfdefaults toFULL, so every sighting has one of these.The text is assembled in
MapsActivityat thenearby_now_with_battery_and_signal/nearby_now_with_batterystrings, so the icon needs to go into that row rather than into the string.Where the icons come from
This project already uses Material Symbols and they are Apache 2.0, so nothing new needs adding. Every icon in
app/src/main/res/drawable/is an exported vector withviewportWidth="960"— that is the Material Symbols export, andhistory_24px.xmlis a good one to copy the shape of, including the?attr/colorControlNormaltint that makes it follow the theme.Please do not pull in an icon dependency for four glyphs.
Things that will trip you up
contentDescription, sobattery_short_fulland friends probably survive as descriptions rather than being deleted. A screen reader user should still hear "battery medium".python scripts/add_strings.pyprints the usage, and--checkruns in CI. Hand-editing ten files is how one locale silently keeps the old wording.SystemColorsLayoutTestis the pattern: build a themed context, inflate, assert. What is worth asserting is that each of the four levels resolves to a different drawable and that the drawable actually loads — a battery icon that silently renders as nothing is the exact failure the history timeline shipped with once.CONTRIBUTING.mdhas the how.What the pull request needs to show
This is a visual change, so the review is visual. Two things, and neither needs a phone:
1. Screenshots — all four states, both themes. Eight images, or four if you put the states side by side. Before and after, so the space saved is visible rather than asserted.
You do not need a device or an emulator of your own.
.claude/skills/device-screenshots/documents the pattern the repo already uses: inflate against a themed context, draw to a bitmap, and write it to the directory the test runner hands you, which comes back to the host.SystemColorsLayoutTestis a worked example, andcreateConfigurationContextwithUI_MODE_NIGHT_YESis how dark mode is rendered without touching a device setting.2. Tests, and a note on what you checked. The bar is not high but it is specific:
contentDescriptionis set for every stateSay what you ran and what you did not. A pull request here is expected to state plainly what was verified and what was assumed — an honest gap is worth more than an implied "it works". If you cannot run the instrumented suite, say so and describe what you checked instead; that is a normal answer and not a blocker.
One habit worth borrowing: break the thing on purpose and confirm the test goes red. Four tests passing first try is a reason for suspicion rather than confidence.
Optional second half: the history list
The per-tag history rows show the raw status byte:
Bits 6-7 of that byte are the battery, and they are now confirmed against real hardware — two AirTags watched across a battery change reported
0x90(low) on months-old cells, then0x10(full) and0x50(medium) once replaced, with every other bit unchanged.But that panel deliberately shows the number and no interpretation, and a comment in
LocationReportFieldsrecords the reason: it is debug metadata, the raw byte is certainly right, and a label beside it would be the app's opinion competing with the tag's on a screen meant for evidence.So treat this half as not yet decided. If you want to do it, say so on this issue first and let it be settled — and whoever does it has to update that comment in the same change, or the code and the file arguing against it will contradict each other.
The first half needs no such discussion. Take it on its own if you prefer.