Skip to content

widget: let the launcher lay out the heart-rate card - #1961

Merged
ryanbr merged 4 commits into
mainfrom
fix/1957-widget-layout-on-device
Sep 7, 2026
Merged

ryanbr merged 4 commits into
mainfrom
fix/1957-widget-layout-on-device

Conversation

@ryanbr

@ryanbr ryanbr commented Sep 7, 2026 •

Copy link
Copy Markdown
Owner

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 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 — 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.

LocalSize being a hint about the cell rather than a measurement of the card is
the 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 range
there 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 Image defaults to ContentScale.Fit, which preserves aspect. Every
sizing 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 834x204
and 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 API
rather than assumed.

Verification

Full Android suite 5544 green, i18n --ci clean, doc-comment lint clean. The
pure-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.

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
ryanbr merged commit 1108050 into main Sep 7, 2026
3 checks passed
@ryanbr
ryanbr deleted the fix/1957-widget-layout-on-device branch September 7, 2026 05:14
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant