Repository navigation
perf(ui): pose the animating surfaces still in battery saver (#909) - #911
Merged
Merged
Conversation
Android half of #909. Apple honours Low Power Mode for its live gauges; here the equivalent flag was read nowhere. `PowerManager` appears once in the tree, in AndroidDiagnostics, and for `isIgnoringBatteryOptimizations` — a different question entirely. `isPowerSaveMode` appeared nowhere at all. The parallel with the Apple side was exact: LiquidSky.kt already carried the comment "Under Reduce Motion it collapses to the static picture (no frame loop)", the same half-implemented pairing #909 found on the sky branch. Battery-saver state is a single process-wide monitor, registered once, read through `rememberPowerSaveMode()`. One receiver rather than one per composable: the liquid primitives alone have 26 call sites, and a DisposableEffect in each would register and unregister a BroadcastReceiver as gauges scroll in and out of composition — churn, in the change that exists to remove per-frame work. The Apple twin is a singleton for the same reason, and like it this is never unregistered. It stays live on ACTION_POWER_SAVE_MODE_CHANGED, so toggling battery saver takes effect without leaving the screen, and registers RECEIVER_NOT_EXPORTED through ContextCompat as WhoopConnectionService does. `rememberPoseStill()` is that OR reduce-motion. Gated: LiquidSky, the three LiquidPrimitives frame loops, TimeOfDayBackground's atmosphere transition, the LiveSessionScreen guardian breath, and the pulsing ConnectionDot halo. The last had no motion gate of any kind — it pulsed under "Remove animations" too, which was already a bug. NOT gated, deliberately: - One-shot animations. CountUpText, staggeredAppear and the 160 ms liquidPress tween settle and stop, so they are not the cost battery saver asks an app to avoid, and #909 gated only its TimelineViews. - TodayScreen's drag-reorder auto-scroll. It is a withFrameNanos loop, but it runs only while the user's finger is down. There is no idle to save, and quieting it would strip the auto-scroll out from under an active drag. `PoseStillCoverageTest` censuses withFrameNanos / rememberInfiniteTransition across com/noop/ui and fails on any file that does not consult the gate, with the drag loop exempted by name and by reason. That census is how three surfaces I had missed were found: my own survey used `head -8` and truncated, and I would have shipped LiveSessionScreen, Components and TodayScreen ungated on the strength of it. The test also pins that the gate reads both signals and stays live, since losing either half is invisible — the screen looks right in whichever mode still works. Named `renderStill` at the call sites rather than `posed`, because LiquidPrimitives already binds `posed` to `LiquidSim.posed(...)` in its static branches and the first version of this shadowed it. The ConnectionDot gate is hoisted to a local rather than called inside `if (pulsing && !rememberPoseStill())`, so the composable is not invoked from the right-hand side of a short-circuit. Verified: the coverage guard passes here, fails against the ungated tree, and fails rather than skips when the source is unlocatable; exactly one registerReceiver remains under com/noop/ui. No measurement on Android — the Apple numbers (~18% of a core idle, 0.0% posed) are cited as the shape, not restated as this platform's.
ryanbr
force-pushed
the
perf/android-low-power-static-gauges
branch
from
July 28, 2026 00:20
03eed40 to
2b3ab03
Compare
taylor-cao
pushed a commit
to taylor-cao/noop
that referenced
this pull request
Jul 28, 2026
…calibration count (ryanbr#530) * Liquid Today: carry Charge like Rest, honest state pill, battery-ring truth table (B3/A3/B2/B1) Liquid Today is the DEFAULT screen (iOS + macOS), but the v8 rebuild dropped carry-overs the classic TodayView has. On an unscored-today day (post-04:00 rollover, or a no-wear night) the Charge and Effort heroes read `displayDay?.recovery`/`.strain` RAW and blanked, while the Rest hero (freshRestScore) and vitals (Repository.lastVitalsDay) carried right beside them — and the Charge pill still said "Calibrating" at a wearer 14 nights past the seed gate. Liquid was the only surface that blanked; classic carries via lastScoredCharge (ryanbr#543), widget/watch/Live Activity via widgetAnchor (ryanbr#911), Android via TodayScreen.lastScoredRecoveryDay. B3: ChargeDisplay.resolve carries the last scored night's REAL Charge, stamped "Last night · <date>" (or "Latest sleep" once ryanbr#779-stale) via the SAME TodayView.lastScoredRecoveryDay + carriedCaption the classic screen uses, so the two Today screens can't drift. The state pill tells the truth — only a genuinely calibrating baseline reads "Calibrating"; a trusted baseline with no scored night reads "No data". The Key-Metrics Recovery tile reads the same resolved value so tile and hero can't disagree. Effort deliberately does NOT carry: it is today's accumulation, so yesterday's number would be a false statement, not a stale one. A3/B2: the strap-battery ring resolves through StrapBatteryDisplay so a known charging state survives a missing %, and a % never outlives its link. B1: LiquidSyncStatusRow surfaces backfill progress in the Data Sources card. Adds a DEBUG-only `--demo-screen liquidtoday` (plain "today" renders classic). Tests: LiquidChargeCarryTests (9), LiquidBatteryDisplayTests. App-target Swift, so no CI covers it — verified the full macOS StrandTests suite green, the iOS target builds, and drove it in the simulator on the real on-device DB with today's row nulled: before = Charge "–"/pill "Calibrating"; after = Charge 47 carried from Jul 14/pill "Last night". * Liquid Today: surface the "N of 4 nights" calibration count The v8 Liquid Today (the default iOS Today) showed a bare "Calibrating" for a pre-seed-gate baseline, dropping the "N of 4 nights" progress the classic TodayView surfaces (calibrationDetail / ScoreStatePill). A brand-new user in their first 4 nights saw the progress on classic but not on Liquid. ChargeDisplay already carried the count (.calibrating(nights:), 9251ae2); this adds ChargeDisplay.calibrationDetail returning classic's exact copy ("Learning your baseline, N of 4 nights.") and swaps it into the synthesis card in place of the readiness one-liner (chargeDisplay.calibrationDetail ?? synthLine) — the same replacement classic makes (calibrationDetail ?? synthesisCardDetail). The compact greeting pill stays short ("Calibrating") per its fixedSize row, so the count lives in the card. Reuses classic's String Catalog key verbatim; both Today screens now read identically. New-users-only, no owner impact. Android TodayScreen already surfaces this, so no Kotlin twin is needed — iOS Liquid catching up to existing behaviour. Verified (app-target Swift, not covered by CI): LiquidChargeCarryTests (test-first, RED→GREEN, 11/11) on the resolver/label; macOS Strand + iOS NOOPiOS app targets compile; --demo-screen liquidtoday on a clean sim renders "Learning your baseline, 0 of 4 nights." in the synthesis card with the short "Calibrating" pill. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
ryanbr
pushed a commit
that referenced
this pull request
Aug 2, 2026
…ilt sensor (#909) (#931) LiquidMotion ran CMMotionManager device motion at 60 Hz to drive the decorative slosh. onDisappear does not fire when the app is backgrounded, and NOOP declares bluetooth-central + location, so with a strap connected that sensor fusion kept being delivered all day behind the lock screen for a picture nobody could see. It now stops at the app boundary and re-arms on foreground, and startIfWanted() consults the gate itself rather than trusting call sites. A census (QuietMotionCoverageTests, the Apple twin of #911's PoseStillCoverageTest) found five more never-settling loops #909 never reached: the day-cycle atmosphere drift, the guardian breath, the ConnectionDot and PulseDot halos, the onboarding glows and radar sweep, and RecordingStatusLight's sync ring - which had no motion gate at all, so it pulsed under system Reduce Motion too, while the strap was offloading history. The gate moves into StrandDesign as NoopMotionState so the design system's own surfaces can reach it, reading three OR-ed signals: system Reduce Motion, Low Power Mode, and a new in-app "Reduce motion in NOOP" toggle (default OFF). The census test lives in the StrandDesign package deliberately: swift-packages runs it by default, and it fails loudly rather than skipping if it cannot find the tree or scans too few files - so it cannot pass by censusing nothing. Known asymmetry: the in-app toggle is Apple-only; Android's gate stays the two-signal rememberPoseStill(). Follow-up rather than a blocker.
simoncad7
pushed a commit
to simoncad7/noop
that referenced
this pull request
Aug 17, 2026
…calibration count (ryanbr#530) * Liquid Today: carry Charge like Rest, honest state pill, battery-ring truth table (B3/A3/B2/B1) Liquid Today is the DEFAULT screen (iOS + macOS), but the v8 rebuild dropped carry-overs the classic TodayView has. On an unscored-today day (post-04:00 rollover, or a no-wear night) the Charge and Effort heroes read `displayDay?.recovery`/`.strain` RAW and blanked, while the Rest hero (freshRestScore) and vitals (Repository.lastVitalsDay) carried right beside them — and the Charge pill still said "Calibrating" at a wearer 14 nights past the seed gate. Liquid was the only surface that blanked; classic carries via lastScoredCharge (ryanbr#543), widget/watch/Live Activity via widgetAnchor (ryanbr#911), Android via TodayScreen.lastScoredRecoveryDay. B3: ChargeDisplay.resolve carries the last scored night's REAL Charge, stamped "Last night · <date>" (or "Latest sleep" once ryanbr#779-stale) via the SAME TodayView.lastScoredRecoveryDay + carriedCaption the classic screen uses, so the two Today screens can't drift. The state pill tells the truth — only a genuinely calibrating baseline reads "Calibrating"; a trusted baseline with no scored night reads "No data". The Key-Metrics Recovery tile reads the same resolved value so tile and hero can't disagree. Effort deliberately does NOT carry: it is today's accumulation, so yesterday's number would be a false statement, not a stale one. A3/B2: the strap-battery ring resolves through StrapBatteryDisplay so a known charging state survives a missing %, and a % never outlives its link. B1: LiquidSyncStatusRow surfaces backfill progress in the Data Sources card. Adds a DEBUG-only `--demo-screen liquidtoday` (plain "today" renders classic). Tests: LiquidChargeCarryTests (9), LiquidBatteryDisplayTests. App-target Swift, so no CI covers it — verified the full macOS StrandTests suite green, the iOS target builds, and drove it in the simulator on the real on-device DB with today's row nulled: before = Charge "–"/pill "Calibrating"; after = Charge 47 carried from Jul 14/pill "Last night". * Liquid Today: surface the "N of 4 nights" calibration count The v8 Liquid Today (the default iOS Today) showed a bare "Calibrating" for a pre-seed-gate baseline, dropping the "N of 4 nights" progress the classic TodayView surfaces (calibrationDetail / ScoreStatePill). A brand-new user in their first 4 nights saw the progress on classic but not on Liquid. ChargeDisplay already carried the count (.calibrating(nights:), 9251ae2); this adds ChargeDisplay.calibrationDetail returning classic's exact copy ("Learning your baseline, N of 4 nights.") and swaps it into the synthesis card in place of the readiness one-liner (chargeDisplay.calibrationDetail ?? synthLine) — the same replacement classic makes (calibrationDetail ?? synthesisCardDetail). The compact greeting pill stays short ("Calibrating") per its fixedSize row, so the count lives in the card. Reuses classic's String Catalog key verbatim; both Today screens now read identically. New-users-only, no owner impact. Android TodayScreen already surfaces this, so no Kotlin twin is needed — iOS Liquid catching up to existing behaviour. Verified (app-target Swift, not covered by CI): LiquidChargeCarryTests (test-first, RED→GREEN, 11/11) on the resolver/label; macOS Strand + iOS NOOPiOS app targets compile; --demo-screen liquidtoday on a clean sim renders "Learning your baseline, 0 of 4 nights." in the synthesis card with the short "Calibrating" pill. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
simoncad7
pushed a commit
to simoncad7/noop
that referenced
this pull request
Aug 17, 2026
) (ryanbr#911) Android half of ryanbr#909. Apple honours Low Power Mode for its live gauges; here the equivalent flag was read nowhere. `PowerManager` appears once in the tree, in AndroidDiagnostics, and for `isIgnoringBatteryOptimizations` — a different question entirely. `isPowerSaveMode` appeared nowhere at all. The parallel with the Apple side was exact: LiquidSky.kt already carried the comment "Under Reduce Motion it collapses to the static picture (no frame loop)", the same half-implemented pairing ryanbr#909 found on the sky branch. Battery-saver state is a single process-wide monitor, registered once, read through `rememberPowerSaveMode()`. One receiver rather than one per composable: the liquid primitives alone have 26 call sites, and a DisposableEffect in each would register and unregister a BroadcastReceiver as gauges scroll in and out of composition — churn, in the change that exists to remove per-frame work. The Apple twin is a singleton for the same reason, and like it this is never unregistered. It stays live on ACTION_POWER_SAVE_MODE_CHANGED, so toggling battery saver takes effect without leaving the screen, and registers RECEIVER_NOT_EXPORTED through ContextCompat as WhoopConnectionService does. `rememberPoseStill()` is that OR reduce-motion. Gated: LiquidSky, the three LiquidPrimitives frame loops, TimeOfDayBackground's atmosphere transition, the LiveSessionScreen guardian breath, and the pulsing ConnectionDot halo. The last had no motion gate of any kind — it pulsed under "Remove animations" too, which was already a bug. NOT gated, deliberately: - One-shot animations. CountUpText, staggeredAppear and the 160 ms liquidPress tween settle and stop, so they are not the cost battery saver asks an app to avoid, and ryanbr#909 gated only its TimelineViews. - TodayScreen's drag-reorder auto-scroll. It is a withFrameNanos loop, but it runs only while the user's finger is down. There is no idle to save, and quieting it would strip the auto-scroll out from under an active drag. `PoseStillCoverageTest` censuses withFrameNanos / rememberInfiniteTransition across com/noop/ui and fails on any file that does not consult the gate, with the drag loop exempted by name and by reason. That census is how three surfaces I had missed were found: my own survey used `head -8` and truncated, and I would have shipped LiveSessionScreen, Components and TodayScreen ungated on the strength of it. The test also pins that the gate reads both signals and stays live, since losing either half is invisible — the screen looks right in whichever mode still works. Named `renderStill` at the call sites rather than `posed`, because LiquidPrimitives already binds `posed` to `LiquidSim.posed(...)` in its static branches and the first version of this shadowed it. The ConnectionDot gate is hoisted to a local rather than called inside `if (pulsing && !rememberPoseStill())`, so the composable is not invoked from the right-hand side of a short-circuit. Verified: the coverage guard passes here, fails against the ungated tree, and fails rather than skips when the source is unlocatable; exactly one registerReceiver remains under com/noop/ui. No measurement on Android — the Apple numbers (~18% of a core idle, 0.0% posed) are cited as the shape, not restated as this platform's.
simoncad7
pushed a commit
to simoncad7/noop
that referenced
this pull request
Aug 17, 2026
…ilt sensor (ryanbr#909) (ryanbr#931) LiquidMotion ran CMMotionManager device motion at 60 Hz to drive the decorative slosh. onDisappear does not fire when the app is backgrounded, and NOOP declares bluetooth-central + location, so with a strap connected that sensor fusion kept being delivered all day behind the lock screen for a picture nobody could see. It now stops at the app boundary and re-arms on foreground, and startIfWanted() consults the gate itself rather than trusting call sites. A census (QuietMotionCoverageTests, the Apple twin of ryanbr#911's PoseStillCoverageTest) found five more never-settling loops ryanbr#909 never reached: the day-cycle atmosphere drift, the guardian breath, the ConnectionDot and PulseDot halos, the onboarding glows and radar sweep, and RecordingStatusLight's sync ring - which had no motion gate at all, so it pulsed under system Reduce Motion too, while the strap was offloading history. The gate moves into StrandDesign as NoopMotionState so the design system's own surfaces can reach it, reading three OR-ed signals: system Reduce Motion, Low Power Mode, and a new in-app "Reduce motion in NOOP" toggle (default OFF). The census test lives in the StrandDesign package deliberately: swift-packages runs it by default, and it fails loudly rather than skipping if it cannot find the tree or scans too few files - so it cannot pass by censusing nothing. Known asymmetry: the in-app toggle is Apple-only; Android's gate stays the two-signal rememberPoseStill(). Follow-up rather than a blocker.
xxblyxx
added a commit
to xxblyxx/noop
that referenced
this pull request
Aug 21, 2026
The Home Screen widget showed heart rate, battery and the connection dot
but blanked Charge, Effort, Rest, HRV and Resting HR — while Today showed
four of those five as real numbers.
`WidgetSnapshot.publish` resolved ONE recovery-gated anchor day
(`Repository.widgetAnchor`) and read Charge, Effort, HRV and Resting HR off
it, with the `sleep_performance` read nested inside `if let day`. That
anchor returns non-nil only when some row carries a non-null `recovery`, so
a store with nothing scored blanked all five at once. Only bpm/battery/
bonded survived, because they come off `model.live` and never touch the
anchor. Today does not have this problem: it resolves the same five stats
through four independent, mostly recovery-INDEPENDENT selectors, so one
null recovery column blanked five widget fields but only one app field.
Adds `Repository.glanceFields`, a pure injectable resolver that gives each
stat the selector its Today call site uses. It invents no new selector — it
composes four that already exist:
Charge widgetAnchor (the one field the gate belongs on)
Effort today's own row never carries; yesterday's strain
shown as today's is a false statement
Rest TodayView.freshRestScore the shared ryanbr#977 staleness gate that
WatchSessionBridge already adopted;
no longer nested behind the anchor
HRV / RHR today-first per-field carry over lastVitalsDay, which is
explicitly recovery-independent
`todayKey` is pinned to `todayRow?.day ?? logicalKey` (byte-identical to
widgetAnchor's own carriedKey), NOT the 2-arg lastVitalsDay overload's
`max(logicalKey, localKey)` — those diverge in the ryanbr#304 pre-04:00 window.
Unreachable today, but it is the near-miss class that produced ryanbr#911.
Charge stays blank on this store because no row carries a recovery score at
all — the same reason Today's Charge hero is blank. That is a separate
data gap, deliberately not addressed here.
Render side unchanged: NOOPWidget already renders every field honest-null,
and the wire schema is untouched, so no App-Group migration.
`exploreSeries` is now unconditional on the full publish. It was already
paid whenever an anchor existed; `publishLive` still bypasses it, so the
high-frequency HR/battery path is unaffected.
Verification: `xcodegen generate` + `xcodebuild` BUILD SUCCEEDED for both
the macOS `Strand` scheme and the iOS `NOOPiOS` scheme (which compiles the
embedded NOOPiOSWidgets extension) — CI does not build the app targets in
this repo. New `StrandTests/WidgetGlanceFieldsTests` (14 tests) passes
14/14 and `WidgetAnchorTests` still passes 9/9 on `platform=macOS`; the new
suite is pure — no clock, no AppModel, no store, no strap. Not yet
confirmed on device.
Kotlin twin written but NOT compiled locally (no JDK, no Android SDK).
Both Android producers carried the identical coupling and both already had
`resolveTodayRow` in scope; `restPct`/`effortPct` and `widgetRest`/
`widgetEffort` now read today's row while recovery keeps the anchor.
Android's WidgetSnapshot has no HRV or resting-HR field, so `lastVitalsRow`
does not enter the twin. `StrainScorer.effectiveEffort` deliberately left
alone — Android's Today hero uses it and iOS's does not, a pre-existing
divergence out of scope here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FfTpUwbM2d7TmwyGneB7mx
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.
The Android half of #909.
The gap was the same shape, and the comment even matched
Apple honours Low Power Mode for its live gauges after #909. Here the equivalent flag was read nowhere:
PowerManageroccurs once in the tree, inAndroidDiagnostics, and forisIgnoringBatteryOptimizations— a different question (background-work exemption).isPowerSaveModeappeared nowhere at all.LiquidSky.kt:252already carried the mirror of the comment #909 found on the sky branch — "Under Reduce Motion it collapses to the static picture (no frame loop)" — the same half-implemented pairing, on the same surfaces.What it does
rememberPowerSaveMode()reads the flag and stays live onACTION_POWER_SAVE_MODE_CHANGED, so toggling battery saver takes effect without leaving the screen — matching the Apple side, which observesNSProcessInfoPowerStateDidChangefor the same reason. RegisteredRECEIVER_NOT_EXPORTEDviaContextCompat, the patternWhoopConnectionServicealready uses.rememberPoseStill()is that OR reduce-motion.Gated:
LiquidSky, the threeLiquidPrimitivesframe loops,TimeOfDayBackground's atmosphere transition, theLiveSessionScreenguardian breath, and the pulsingConnectionDothalo. That last one had no motion gate of any kind — it pulsed under "Remove animations" too, which was already a bug.Not gated, deliberately:
CountUpText,staggeredAppear, the 160 msliquidPresstween — they settle and stop, so they are not the cost battery saver asks an app to avoid, and perf(liquid): pose the live gauges still in Low Power Mode #909 gated only itsTimelineViews.TodayScreen's drag-reorder auto-scroll. AwithFrameNanosloop, but it runs only while the user's finger is down. No idle to save, and quieting it would strip auto-scroll out from under an active drag.The test earned itself immediately
PoseStillCoverageTestcensuseswithFrameNanos/rememberInfiniteTransitionacrosscom/noop/uiand fails on any file that does not consult the gate, with the drag loop exempted by name and by reason.That census is how three surfaces I had missed were found. My own survey of the animating code used
head -8and truncated at three files; there are six. Without the test I would have shippedLiveSessionScreen,ComponentsandTodayScreenungated and called it parity.It also pins that the gate reads both signals and stays live, because losing either half is invisible — the screen looks correct in whichever mode still works.
Notes
Named
renderStillat the call sites rather thanposed, becauseLiquidPrimitivesalready bindsposedtoLiquidSim.posed(...)in its static branches and the first version of this shadowed it.No measurement on Android. The Apple numbers (~18% of a core idle, 0.0% posed still) are cited as the shape of the problem, not restated as this platform's. The mechanism is the same — an unbounded frame loop driving canvas work — but the number is unverified here and the description should not imply otherwise.
Independent of #909; touches no shared file, so either order works.