Skip to content

iOS widget: Charge · Effort · Rest as score rings - #1022

Merged
ryanbr merged 2 commits into
ryanbr:mainfrom
akeschmidi:feature/ios-widget-score-rings
Aug 2, 2026
Merged

ryanbr merged 2 commits into
ryanbr:mainfrom
akeschmidi:feature/ios-widget-score-rings

Conversation

@akeschmidi

@akeschmidi akeschmidi commented Aug 1, 2026 •

Copy link
Copy Markdown

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

  • Small/medium: big Charge % + HR + battery (medium also showed Effort as a bolt icon)
  • Effort and Rest were already in WidgetSnapshot from #446 but mostly unused on compact families

After

  • systemSmall / systemMedium: three equal score rings (Charge · Effort · Rest), matching TodayView order and domain colours (chargeColor / effort tint / restColor)
  • Footer keeps live HR + strap battery (medium also shows HRV when present)
  • systemLarge: the same three rings on top, then HRV / Rest HR / live HR / battery grid
  • Lock Screen rectangular: Charge · Effort · Rest triplet (was Charge + HR · Effort only)
  • Widget-safe static ring (no GlowRing draw-in @State) so arcs don't freeze empty when WidgetKit skips onAppear
  • Gallery placeholder Effort set to a realistic 0–100 value so previews show filled arcs

Data path is unchanged: WidgetSnapshot.publish already writes recovery / effort / rest / hrv / restingHr / bpm / battery.

Test plan

  • xcodegen generate
  • xcodebuild -project Strand.xcodeproj -scheme NOOPiOS -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO build (succeeds)
  • On device / simulator: open NOOP so a snapshot publishes, add the NOOP widget (small, medium, large) and confirm three rings match Today
  • Unscored day: rings show – with empty track, not fabricated zeros
  • Lock Screen rectangular accessory shows Charge · Effort · Rest
  • Bonded/disconnected dot still reflects connection state

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 ryanbr left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.yml is disabled, so nothing in CI compiles StrandiOSWidgets/ or StrandiOSShared/. No checks have run on this PR at all yet (workflow approval pending). The xcodebuild line 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.
@akeschmidi

Copy link
Copy Markdown
Author

Thanks for the careful review — all three points addressed in fe3ae97.

1. systemSmall overflow — diameter 52 → 40 (with 10pt padding: 40×3 = 120 ≤ SE’s ~128 content width). Medium/large unchanged.

2. Effort tint — Effort ring now uses the constant StrandPalette.effortColor, matching TodayView.effortRing (and Charge/Rest’s fixed domain colours). Dropped the value-sampled effortTint the old footer bolt used.

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 WidgetPublish reads UnitPrefs.effortScaleKey from the app’s UserDefaults and writes:

  • effort — still the stored 0–100 int (ring fill = effort/100, scale-independent)
  • effortDisplay — pre-formatted "38" or "8.0" (widget extension can’t reach the non–App-Group key)
  • effortWhoop — for accessibility “out of 21” vs “out of 100”

Both new fields are optional so older snapshots still decode; missing effortDisplay falls back to whole-number effort.

Verified: xcodebuild -scheme NOOPiOS -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO build → BUILD SUCCEEDED.

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 ryanbr left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-build on head fe3ae97b merged into current main, both legs green including the macOS StrandTests leg. That is the first and only compile these 241 lines have had, so it is worth an approval on the run before merge.

@ryanbr
ryanbr merged commit 4a89f62 into ryanbr:main Aug 2, 2026
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
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.
ryanbr added a commit that referenced this pull request Sep 2, 2026
#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.
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.

2 participants