Repository navigation
widget: let the launcher lay out the heart-rate card - #1961
Merged
Merged
Conversation
Three things a device showed that no test could. The chart and its scale sat in the left half of the card with dead space beside them. I sized the chart from LocalSize, and a One UI launcher reported a size smaller than the card actually occupied, so every width derived from it was short. The chart now takes the row's remaining width by weight. The bitmap is still measured in pixels, because it has to be, but only to be drawn and then stretched, which a smooth line survives. Layout is the launcher's business. A single reading rendered a scale reading 78, 78, 78. That is honest — with no range there is nothing to scale against — but three labels repeating the headline are noise, so the scale now appears only once there is a range to describe. The time axis went the same way: one instant pinned to the left edge read as a stray rather than an axis, so it waits for a span to label. The stamp left a void beneath it, because a 4x2 cell is taller than this content. It is pinned to the bottom now and reads as a footer. Sizing from LocalSize was the mistake underneath all three. It is a hint about the cell, not a measurement of the card, and I had treated it as the latter.
The layout fix moved the chart to a weight, but left the BITMAP sized from the same LocalSize that under-reports. So the Image was upscaled to fill, and only horizontally, which turns a round stroke elliptical and soft. I had waved that through as "a smooth line survives stretching", which is true of downscaling and false of anisotropic upscaling. Height is now taken exactly as displayed and never scaled, and the width gets what is left of the byte budget, up to twice the request. fitBox could not do this itself: it preserves aspect, so a wider ask would have shrunk the height and traded a horizontal stretch for a vertical one. On the device that produced the screenshot, 2.75x with a 56dp chart, this lands at about 1.02x — effectively one to one. A 3x phone sits at 0.86x, a 14% stretch on a two-pixel stroke, which is sub-pixel. Closing that last fraction would mean raising the payload budget, and a widget that fails to render is a worse outcome than a slightly soft line, so the budget keeps priority. Three tests, including that the budget wins over the headroom rather than the reverse.
…in it The chart was pinned at 56dp on a card with about 36dp going spare, which is why it read as small. Worse, the spacer I added to pin the stamp to the bottom was eating exactly that slack — the fix for one complaint consumed the room the other needed. The chart takes the leftover height itself now, roughly 64% taller. Taller immediately collided with the payload budget. At four bytes a pixel, a 92dp chart left only 518px of width for a card about 834px wide: a 1.6x horizontal stretch, which is the anisotropic upscale removed in the previous commit. Taller and sharper were in direct conflict. They are not, once the bitmap stops paying for an alpha channel it does not use. The trace is one hue over an opaque card, so it is drawn RGB_565 over the card colour, with the gradient fading to that colour rather than to transparent since 565 has no alpha to fade into. Half the bytes a pixel, so the same budget now affords 1034px at that height: a 1.24x DOWNSCALE where it was a 1.6x upscale. Dithering is on, because 565 bands a smooth ramp without it. The budget is unchanged, and the tests now read it in the unit the renderer actually spends rather than assuming four.
Glance's Image defaults to ContentScale.Fit, which preserves aspect. Every sizing decision in this widget assumes the opposite: the width is drawn with headroom SO THAT it downscales to fill, and the height is drawn to an estimate because a weighted box has no knowable size. Both only work if the bitmap stretches. Under Fit it would not have. A 1034x253 trace inside an 834x253 box scales to 834x204 and sits letterboxed, 49px short — which would have quietly undone the height the previous commit had just given it, and made the graph look small again for a completely different reason. Worth noting how this hid: the maths in those two commits was right, and I checked it. What I had not checked was the assumption underneath it, which lived in a default I never wrote down.
ryanbr
added a commit
that referenced
this pull request
Sep 7, 2026
iOS twin of the heart-rate widget (#1957), Android having landed in #1958/#1961. ## What ports, and what deliberately does not `HrTrace` ports whole — retention, normalisation, stats, tick choices. Those are the same decisions about the same heart, and a trace that kept a different window or picked different labels would be a different READING, not a different look. Sixteen Swift tests mirror the Kotlin ones case for case, so a change landing on one side alone fails. The DRAWING does not port, and should not: | | Android | iOS | |---|---|---| | renderer | Bitmap under a byte budget, RGB_565 | stroked SwiftUI `Path` | | why | Glance compiles to RemoteViews: no Canvas, Binder payload ceiling | WidgetKit is SwiftUI, resolution-independent | | sizing | `fitBox` / `widestAtHeight` / headroom | geometry gives the box | So `fitBox`, the byte budget and the pixel depth stay behind — they answer a question this platform does not ask. `encode`/`decode` stay behind too: the snapshot here is already Codable and carries `[HrPoint]` directly, where Android packs a SharedPreferences string. ## A defect found before it shipped `save()` only ran when `renderedContentChanged` was true, and that compares bpm, not history. A steady heart — the ordinary case at rest — would have recorded no point, so the trace would stop advancing while the strap streamed happily and prune itself to empty. `traceNeedsPoint` is the twin of Android's `PushGate` re-admit, keyed on the minute BUCKET so it asks for a write exactly when `HrTrace.append` would record one and never more often. It persists without a reload, since spending a WidgetKit reload a minute is what the dedup above it exists to avoid. ## The accent `StrandPalette.zone5`, which resolves to `#C84E1E` / `#E0662F` — exactly the hexes the Android widget carries as a local mirror. That was luck rather than judgement: the colour chosen by eye there turns out to be the palette's HR zone-5 token. Both sides now record the match, so neither drifts. ## Accessibility SwiftUI can combine the card into one spoken sentence, which Glance cannot — Android had to settle for per-label descriptions because it offers no way to mark a `Text` decorative. So this reads as one label rather than a run of loose numbers. Staleness is not colour-encoded here at all, so nothing needs saying about it. ## What review passes added after this was opened Four gaps, each found by comparing against the Kotlin side rather than by reading the Swift in isolation: - **Spoken text was hardcoded English.** The audit does scan `.accessibilityLabel(`, but it matches a literal immediately after the paren, and these were built in a computed property — so extracting them for readability is what hid them. Worse, after switching to `String(localized:)` the audit stayed green with the keys still ABSENT from the catalog: "is it extracted" and "does the key exist" are different questions and it only asks the first. Three keys added across ten locales. - **The trace was not pruned on read.** Android prunes on the way out as well as in; this drew points that had aged out, under a time axis implying they were current. Measured from `entry.date`, since WidgetKit renders an entry at ITS date. - **No age rule on the headline.** Android drops a reading past fifteen minutes and dims one past two. Pruning made the absence incoherent rather than merely wrong: an empty chart under a confident number. `HrDisplay` ports with the same thresholds, anchored on the newest TRACE POINT rather than `updated`, because a publish fires for battery and score changes too. - **Work done twice, on both platforms.** Android sorted in `append` and again in `prune`, and scanned for `stats` once per composable; iOS decoded the App Group snapshot twice per publish. Same shape each time: a helper written to stand alone, called from somewhere that had already done the work. ## Verification Android suite 5544 green, i18n `--ci` clean, doc-comment lint clean. Two catalog entries added across ten locales, TEXT-SPLICED rather than JSON round-tripped since the file is ~6MB — and Italian needed real abbreviation periods to clear the echo gate, the same way German and French did on the Android side. Swift is CI-verified as usual. `StrandTests` runs on the app-build macOS leg, and this PR touches `StrandiOSShared`, `StrandiOSWidgets`, `StrandiOS`, `StrandTests` and `project.yml`, so that job triggers. **Not verified on a device.** The Android one needed three rounds on a real home screen after passing every gate, and nothing here has been near a launcher.
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.
Follow-up to #1958, from putting it on an actual home screen. Three defects, none
of which a test could have shown.
The chart did not span the card
Everything sat in the left portion with dead space beside it. The cause: I sized
the chart from
LocalSize.current, and a One UI launcher reported a size smallerthan the card actually occupied, so every width derived from it was short.
The chart now takes the row's remaining width by weight. The bitmap is still
measured in pixels — it has to be — but only to be drawn and then stretched,
which a smooth line survives without visible cost. Layout is the launcher's
business, not arithmetic of mine.
LocalSizebeing a hint about the cell rather than a measurement of the card isthe mistake underneath all of this, and I had treated it as the latter.
Labels that said nothing
A single reading rendered a bpm scale of
78 / 78 / 78. Honest — with no rangethere is nothing to scale against, which is the behaviour a test pins — but three
labels repeating the headline are noise. The scale now appears only once there is
a range to describe.
The time axis went the same way. One instant pinned to the left edge read as a
stray rather than an axis, so it waits for a span to label.
A void under the stamp
A 4x2 cell is taller than this content, so "Updated" floated with empty card
beneath it. It is pinned to the bottom now and reads as a footer.
Then: the graph was still small
Two things, and the second was self-inflicted.
The chart was pinned at 56dp while the card had roughly 36dp going spare — and
the spacer added above to pin the stamp to the bottom was eating exactly that
slack. The fix for the void consumed the room the graph needed. The chart takes
the leftover height itself now, about 64% taller.
Taller then collided with the payload budget: at four bytes a pixel a 92dp chart
left only 518px of width for an ~834px card, a 1.6x horizontal stretch — the very
upscale removed a commit earlier. They stop competing once the bitmap stops
paying for an alpha channel it never uses. The trace is one hue over an opaque
card, so it is drawn RGB_565 over the card colour, gradient fading to that colour
rather than to transparent. Half the bytes, so the same budget affords 1034px at
that height: a 1.24x DOWNSCALE. Dither is on, since 565 bands a smooth ramp.
And the assumption underneath all of it
Glance's
Imagedefaults toContentScale.Fit, which preserves aspect. Everysizing decision above assumes the opposite — width drawn with headroom SO THAT it
downscales, height drawn to an estimate because a weighted box has no knowable
size. Under
Fit, a 1034x253 trace in an 834x253 box would letterbox to 834x204and sit 49px short, silently undoing the height it had just been given.
The arithmetic in both commits was right and checked. The assumption underneath
it lived in a library default I had never written down, so the two fixes were
correct and jointly inert. Now
FillBounds, verified against the Glance APIrather than assumed.
Verification
Full Android suite 5544 green, i18n
--ciclean, doc-comment lint clean. Thepure-half tests are untouched and still describe the same behaviour: what changed
is which of it gets RENDERED, not what it computes.
Wants another look on a launcher, and specifically once the trace has more than
one point — the single-reading state is the only one seen so far.