Skip to content

perf(ui): pose the animating surfaces still in battery saver (#909) - #911

Merged
ryanbr merged 1 commit into
mainfrom
perf/android-low-power-static-gauges
Jul 28, 2026
Merged

ryanbr merged 1 commit into
mainfrom
perf/android-low-power-static-gauges

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 28, 2026

Copy link
Copy Markdown
Owner

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: PowerManager occurs once in the tree, in AndroidDiagnostics, and for isIgnoringBatteryOptimizations — a different question (background-work exemption). isPowerSaveMode appeared nowhere at all.

LiquidSky.kt:252 already 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 on ACTION_POWER_SAVE_MODE_CHANGED, so toggling battery saver takes effect without leaving the screen — matching the Apple side, which observes NSProcessInfoPowerStateDidChange for the same reason. Registered RECEIVER_NOT_EXPORTED via ContextCompat, the pattern WhoopConnectionService already uses. 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. That last one 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, the 160 ms liquidPress tween — 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 its TimelineViews.
  • TodayScreen's drag-reorder auto-scroll. A withFrameNanos loop, 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

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 of the animating code used head -8 and truncated at three files; there are six. Without the test I would have shipped LiveSessionScreen, Components and TodayScreen ungated 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 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.

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.

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
ryanbr force-pushed the perf/android-low-power-static-gauges branch from 03eed40 to 2b3ab03 Compare July 28, 2026 00:20
@ryanbr
ryanbr merged commit 437ce9d into main Jul 28, 2026
3 checks passed
@ryanbr
ryanbr deleted the perf/android-low-power-static-gauges branch July 28, 2026 00:28
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
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