Repository navigation
iOS widget: Charge · Effort · Rest as score rings - #1022
Conversation
The Home Screen widget only surfaced a big Charge percentage (plus HR/battery), so Effort and Rest — already published in WidgetSnapshot — never appeared on small/medium. Render Today's hero trio as static rings on systemSmall/Medium/Large and the Lock Screen rectangular accessory, with HR/battery kept as a footer. Uses a widget-safe static ring (no GlowRing draw-in animation) so arcs don't freeze empty when WidgetKit skips onAppear. Placeholder Effort bumped to a realistic 0–100 value for the widget gallery.
ryanbr
left a comment
There was a problem hiding this comment.
Good direction, and it lands the right way round: the Android Glance widget has shown the trio since #516, so this closes a real gap rather than inventing a layout. Order is right too — iOS Today's hero is Charge · Effort · Rest (TodayView 2663–2666) while Android's is Rest · Charge · Effort, so matching iOS Today rather than the Android widget is correct.
Verified locally: every symbol resolves (StrandPalette.chargeColor / restColor / effortTint, StrandFont.rounded), the placeholder change is right (StrainScorer.maxStrain == 100, so Effort 38 is a realistic 0–100 value), and Tools/i18n_audit.py --ci main passes — no new un-extracted literals.
Three things before this is ready.
1. The rings overflow systemSmall. small uses .padding(10) and scoreRings(diameter: 52). Three fixed 52pt circles need 156pt, but content width is 158−20 = 138pt on an iPhone 15 Pro, 128pt on an SE, 150pt even on a Pro Max. The .frame(width: diameter, height: diameter) on the ZStack is a hard requirement, so the HStack's equal shares (~46pt) can't compress it — adjacent rings overlap by ~3pt a side. medium (305pt / 3 = 101 vs 72) and large (297 / 3 = 99 vs 88) are fine. Small needs ~40, or a size derived from the container.
2. The Effort ring's tint diverges from Today. Today's effortRing uses the constant StrandPalette.effortColor; Charge and Rest use the constants too. This PR correctly moves Charge and Rest onto chargeColor / restColor, but leaves Effort on the value-sampled effortTint(fraction:) carried over from the old code. Result: two rings hold a fixed domain colour and the third shifts ember→amber with the value, in a layout whose premise is that all three match Today. effortColor makes it consistent.
3. Effort ignores the 0–21 toggle (#313). Today honours effortScale — effortGaugeValue / effortGaugeMax, one decimal on the WHOOP axis. The widget always renders the raw 0–100 int, so a user on the WHOOP scale sees 38 on the widget and 8.0 in the app for the same day. That mismatch pre-dates this PR, but it was a bolt icon in a footer before and is now a hero number, so it gets a lot more visible. effortScale is a plain @AppStorage(UnitPrefs.effortScaleKey), not the app-group suite, so the widget can't read it as-is — the scale (or the pre-converted display value) would need to go into WidgetSnapshot. Reasonable to split into a follow-up, but worth deciding rather than inheriting.
Non-blocking notes:
- The widget's labels stay hardcoded English while the Android widget localizes via
uiString(R.string.l10n_noop_glance_widget_charge_…). The audit passes because it's baseline-tracked debt, but a German user gets "Charge / Effort / Rest", not the Erholung / Belastung / Ruhe in the description above. - The two widgets now diverge in emphasis: Android centres and enlarges Charge (30sp vs 22sp) and bands Charge/Rest by value; this goes equal-weight with fixed tints. Feature-level parity is what the repo asks for, so this isn't a blocker — just flagging that they'll read quite differently side by side.
max(0.0001, fraction)with a round cap draws a dot at a genuine zero rather than nothing. That reads as the correct call (a scored 0 is data, not absence) — worth keeping.- Heads-up on verification:
app-build.ymlis disabled, so nothing in CI compilesStrandiOSWidgets/orStrandiOSShared/. No checks have run on this PR at all yet (workflow approval pending). Thexcodebuildline in the test plan is the only thing standing between this and main, so it's worth confirming the small-family layout on a real device once the diameter is sorted.
…cale 1. systemSmall ring diameter 52 → 40 so three hard-framed circles fit the narrowest content width (SE ~128pt after padding) without overlapping. 2. Effort ring uses StrandPalette.effortColor like Today, not the value-sampled effortTint ramp the old footer bolt used. 3. Honour the ryanbr#313 0–21 Effort toggle at publish time: WidgetSnapshot carries effortDisplay + effortWhoop (optional for old snapshots); ring fill stays on the stored 0–100 axis so arc length is scale-independent. The widget extension cannot read the app's plain UserDefaults effort.scale key.
|
Thanks for the careful review — all three points addressed in fe3ae97. 1. systemSmall overflow — diameter 2. Effort tint — Effort ring now uses the constant 3. #313 0–21 Effort scale — decided to fix it in this PR rather than inherit, since Effort is now a hero number. At publish time
Both new fields are optional so older snapshots still decode; missing Verified: Non-blocking items (labels i18n, Android emphasis) left as follow-ups — happy to take the label localizations in a small follow-up if you want them with this. |
ryanbr
left a comment
There was a problem hiding this comment.
All three addressed, and the #313 one is a better fix than I asked for. Approving.
Small-family overflow. 52pt → 40pt with the arithmetic recorded in the comment (40 × 3 = 120 ≤ 128 SE / 138 15 Pro), and lineWidth dropped to 4 to keep the proportion. That is the right way to leave it — the next person to touch the diameter can see what constraint they are up against.
Effort tint. Now StrandPalette.effortColor, matching TodayView.effortRing, with a comment naming the value-sampled ramp it replaced so it does not drift back.
The #313 scale. I expected the display value to follow the toggle and the ring to follow it too, which would have been wrong. Splitting them is better: effortDisplay carries the WHOOP-axis string while fraction stays on the stored 0–100 axis, so a 38 and an 8.0 draw the same arc and two users comparing screenshots still see comparable rings. accessibilityOutOf switching to 21 is a detail I would not have thought to ask for.
Pre-formatting at publish time is also the correct architecture rather than a workaround: effortScaleKey is a plain @AppStorage outside the App Group, so the extension genuinely cannot read it, but the publisher runs in the app and can. The format matches TodayView.effortRing exactly — one decimal on the WHOOP axis, whole number on 0–100.
Checked separately, since it is the thing this change could quietly break: WidgetSnapshot stays decode-compatible. Both new fields are optional with nil defaults, so Swift's synthesised Codable reads an already-persisted snapshot without them, and the widget falls back to the whole-number effort. The comment says so too.
Two notes, neither blocking:
- A scale change is not reflected until the next publish. Flip the toggle in Settings and the widget keeps the old formatting until something calls
WidgetSnapshot.publish— backgrounding the app is enough, and there are nine publish sites, so it self-corrects quickly. Worth knowing rather than fixing. - This PR still has no CI of its own — the checks are pending workflow approval for an external contributor. I built it directly instead:
app-buildon headfe3ae97bmerged into current main, both legs green including the macOSStrandTestsleg. That is the first and only compile these 241 lines have had, so it is worth an approval on the run before merge.
The Home Screen widget showed a large Charge percentage with sparse footer icons; Effort and Rest were already in WidgetSnapshot from ryanbr#446 but unused on the compact families. Small and medium now show the three daily scores as rings in Today's order, large keeps its stat grid beneath them, and the Lock Screen rectangular accessory carries the same trio. Rings are static rather than GlowRing's draw-in animation: WidgetKit does not reliably fire onAppear, so an animated ring can freeze empty until the next timeline rebuild. An unscored day renders as a dash over an empty track, never a fabricated zero. Effort honours the ryanbr#313 scale (ryanbr#1022 review): the display string carries the WHOOP 0-21 value while the arc fraction stays on the stored 0-100 axis, so rings stay comparable between users on different scales. Pre-formatted at publish time because effortScaleKey is a plain @AppStorage outside the App Group. The new WidgetSnapshot fields are optional, so an already-persisted snapshot still decodes. Merged with --admin: this fork PR's workflows were never triggered, so it carried no checks. Verified out of band instead - app-build green on both legs against current main (the only compile these 241 lines have had), plus doc-comments and i18n run locally on the merge result. iOS-only, so Android CI does not apply.
#1798) The footer under the score rings paired heart rate with waveform.path.ecg and HRV with heart.fill - the inverse of the pairing every other surface uses. TodayView renders the two as adjacent rows the other way round, and DashboardCards, TodayCustomizationMetadata, MetricCatalog and this extension's own Live Activity all agree with TodayView. The footer had been the lone outlier since #1022. It matters more than a mismatched glyph usually would, because the footer shows no text labels at all: the icon is the only thing naming each number. The inversion was an understandable one, and the doc comment now names it so it does not get "corrected" back. waveform.path.ecg IS the right symbol for Live HR as a feature - RootView, RootTabView and HomeScreenQuickActions all use it for the Live screen. As a metric icon it is HRV's, and this footer is the one place that draws both metrics side by side. Also closes the accessibility gap in the same views. The footer vitals and the large-grid stat cells were bare numbers with no unit and no metric name, so VoiceOver read "58", "64", "84 percent", and "64" / "ms" / "HRV" as separate fragments in value-before-label order with the units spelled out letter by letter. All four labelled graphics in the file now share one shape: accessibilityElement(children: .ignore), then label, then value. Spoken text is carried separately from rendered text so the unit reaches a reader who cannot see the glyph, and it falls back to the rendered value rather than to "No data" so an omission degrades instead of lying. Literals stay plain rather than String(localized:), for the reason already documented on accessoryScore: this extension does not carry the app's string catalog. Verified with an app-build dispatch on the merge result - Strand (macOS, x86_64+arm64) and NOOPiOS (iOS Simulator) both compile. StrandiOSWidgets is app-target Swift that no default CI builds, so no green check on the PR itself covered that. Reported in #1795 by @Sneheth.
Summary
Improves the iOS Home Screen / Lock Screen widget so it shows the same three daily scores as Today's hero — Charge (Erholung), Effort (Belastung), and Rest (Ruhe) — as score rings, instead of only a large Charge percentage with sparse footer icons.
Before
%+ HR + battery (medium also showed Effort as a bolt icon)WidgetSnapshotfrom#446but mostly unused on compact familiesAfter
chargeColor/ effort tint /restColor)GlowRingdraw-in@State) so arcs don't freeze empty when WidgetKit skipsonAppearData path is unchanged:
WidgetSnapshot.publishalready writes recovery / effort / rest / hrv / restingHr / bpm / battery.Test plan
xcodegen generatexcodebuild -project Strand.xcodeproj -scheme NOOPiOS -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO build(succeeds)–with empty track, not fabricated zeros