Skip to content

Live activity screen rebuild, background route recording, and a real share card - #162

Merged
abdulsaheel merged 8 commits into
mainfrom
feat/live-activity-and-share
Jul 27, 2026
Merged

Live activity screen rebuild, background route recording, and a real share card#162
abdulsaheel merged 8 commits into
mainfrom
feat/live-activity-and-share

Conversation

@abdulsaheel

@abdulsaheel abdulsaheel commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

User description

Five commits, each reviewable on its own. The privacy one (10afbe3) wants your eyes specifically — it changes a user-facing promise.

Started from "the activity screen looks bad" and "the app closes in the middle of a ride", and both turned out to be structural rather than cosmetic.


8a7825f — prune the design system to what actually ships

Scanned every public type in ui/design + ui/kit for call sites outside its own file and the gallery, then deleted what had none: RadialHeatmap, RecapCard, AreaSpark, DotMatrix, CalendarHeatmap, StatTile, BaselineProgress, NightCard, NavPillAction, StateChips, OrbitScore's satellite layer, and the dead TimelineScreen wrapper.

Where a deleted component carried a real invariant, the test moved rather than went — RecapCard's "a missing day holds its slot" now runs against MiniBars, whose invariant it actually was.

−1060 lines.

f5cda0b — stop losing runs and rides mid-session

Five independent causes, each separately sufficient. The two least obvious:

  • Android FGS type silently stripped. EdgeTrackingService.start(Context) sends no EXTRA_LOCATION, defaulting to false — and CompanionBridge.onDeviceAppeared calls it whenever the band re-enters BLE range, which happens routinely mid-run from arm-swing dropouts. That re-startForeground'd as connectedDevice only. The Dart-side sticky flag could never protect it; these callers never go through Dart.
  • After a process restart, route recording was dead for the rest of the workout. The timer, calories and strain all came back. The map didn't, and you only found out at the finish screen.

10afbe3 — background route recording

Without the location background mode iOS suspends within seconds of a screen lock. Scope is tight and every claim was verified, not assumed:

  • Authorization stays when-in-usegeolocator_apple's handler is an if/else if, and because we ship NSLocationWhenInUseUsageDescription the Always branch is unreachable
  • Session-scoped: _settings() → one caller → one caller, torn down on every workout-end path
  • Blue location indicator on throughout
  • Routes still only reach the on-device workout_route table

The old string said "OpenStrap does not track your location in the background" — false the moment this ships, so it's rewritten. Both privacy docs gain a Location and workout routes section; neither mentioned location at all, which was already a gap.

No store privacy-label change needed. Apple's "collect" means transmission off device and explicitly excludes on-device-only processing. Android uses FOREGROUND_SERVICE_LOCATION, not ACCESS_BACKGROUND_LOCATION, so no Play declaration is triggered. Guideline 2.5.4 is the thing to satisfy, via the purpose strings.

15d3f2e — rebuild the live session screen

The overlaps were structural: one flat Stack of absolutely-positioned layers with no relationship between them. The re-centre button was pinned bottom: 96 while the panel is far taller, so it rendered underneath it. Now a bounded hero and a metric sheet as siblings in a Column — overlap is impossible rather than unlikely.

Bugs fixed alongside, all reported from real use:

symptom cause
pace read 40:32 /km after barely moving averaged over elapsed, not moving, time
5-min confetti re-fired on every screen return fired-milestone set lived on the screen's State
map greyed out past a zoom TileLayer.maxZoom means "draw nothing above this"; wanted maxNativeZoom
zone colours invisible Z0 measured 1.03:1 — it mapped to cool, a surface token, and Z0 is the resting zone
labels unreadable Colors.white30 = 2.72:1 where small text needs 4.5:1

Contrast was measured, not eyeballed, and zone_contrast_test.dart guards all six zones at ≥3:1.

062884a — a real share card

Sharing rasterised the entire finish card into one tall PNG and handed it to the OS sheet unseen. Now composed for the job: map full-bleed, one headline figure, three stats, nothing else, with a preview before send. Reachable from the workout detail screen too — it was finish-screen only, so leaving that screen made a workout unshareable forever.

Includes a release-only bug I shipped and had to fix: the preview called debugNeedsPaint, whose value is only assigned inside an assert. Stripped in release ⇒ LateInitializationError ⇒ sharing worked in debug and failed on every real build. no_debug_only_apis_test.dart now greps for every SDK getter with that shape (denylist derived from the SDK, not guessed).


Verification

  • 957 tests green (flutter test --concurrency=1), up from 914
  • flutter analyze clean
  • flutter build apk --release — release specifically, because the debugNeedsPaint class of bug is invisible in debug
  • flutter build ios --debug --no-codesign

Regression tests were checked by deliberately reintroducing each bug and confirming they fail. That caught one of my own tests being vacuous: it asserted on the session clock, which clears the sheet even when broken. Rewritten against the bottom-most hero element; it now fails by 15.5 px.


PR Type

Enhancement, Bug fix


Description

  • Rebuilt live-session screen layout (Column hero+sheet) fixing overlapping controls, zone colour bugs, and battery-wasting animations on map view

  • Added workout_share_card.dart with a dedicated share-card composer and preview screen, replacing the raw screenshot approach

  • Fixed mid-session workout loss: Android FGS type stripping, milestone re-fire on screen return, moving-pace vs elapsed-pace, and derive scheduler now defers heavy compute during live workouts

  • Pruned dead design-system components (−1060 lines) and added splits/route-stat rows to the finish screen


Diagram Walkthrough

flowchart LR
  A["Live Session Screen\n(flat Stack)"] -- "Column hero+sheet\nlayout" --> B["_HeroCore / _SessionSheet\n(no overlap)"]
  B -- "map on" --> C["_LiveRouteMap\n(hero region)"]
  B -- "heart on" --> D["_HeroCore\n(LayoutBuilder ring)"]
  B -- "sheet" --> E["_SessionSheet\n(GPS stats + hold-to-finish)"]
  F["_share() rasterise _cardKey"] -- "replaced by" --> G["buildWorkoutShareData\n+ WorkoutSharePreviewScreen"]
  H["DeriveScheduler"] -- "_workoutActive gate" --> I["Defer heavy derive\nduring live workout"]
  J["_milestones (State-local)"] -- "moved to" --> K["activeWorkout.firedMilestones\n(session-scoped)"]
Loading

File Walkthrough

Relevant files
Enhancement
6 files
live_session_screen.dart
Full layout rebuild: Column hero+sheet, zone colour fix, share card
+1450/-728
workout_share_card.dart
New dedicated share-card composer and preview screen         
+602/-0 
screen_wake.dart
New screen-wake helper for background route recording sessions
+61/-0   
route_map.dart
Route map widget updates for new layout and stat bar changes
+173/-31
state_chips.dart
StateChipView pill replaces loose text on Today readiness ring
+94/-89 
tokens.dart
Add zoneOnDark colour ramp for dark-background zone rendering
+64/-7   
Bug fix
3 files
derive_scheduler.dart
Defer heavy derivation while a live workout is running     
+33/-2   
app_state.dart
Wire workout-active flag into derive scheduler and share flow
+44/-0   
EdgeTrackingService.kt
Fix Android FGS type stripping mid-session on BLE reconnect
+39/-1   
Miscellaneous
4 files
charts.dart
Remove dead ornamental chart widgets, update header comment
+5/-417 
timeline_screen.dart
Remove unused standalone TimelineScreen wrapper widget     
+5/-74   
orbit_score.dart
Remove unused satellite layer from OrbitScore                       
+33/-201
gallery_screen.dart
Prune gallery to only cover shipped components                     
+149/-77
Tests
5 files
workout_share_card_test.dart
Tests for share card data composition and edge cases         
+280/-0 
live_session_layout_test.dart
Tests for live session layout correctness and milestone dedup
+181/-0 
workout_reliability_test.dart
Tests for mid-session workout reliability fixes                   
+202/-0 
no_debug_only_apis_test.dart
Test that debug-only APIs are not called in production paths
+111/-0 
zone_contrast_test.dart
Tests that zone colours meet contrast on dark background 
+88/-0   
Configuration changes
1 files
Info.plist
Add location background mode for iOS route recording         
+29/-13 
Documentation
1 files
privacy.html
Update privacy policy for background location usage           
+32/-1   
Additional files
21 files
PRIVACY.md +29/-1   
NativeChannels.kt +27/-0   
AppDelegate.swift +9/-0     
briefing_engine.dart +1/-1     
gps_source.dart +21/-10 
main.dart +12/-0   
design.dart +0/-1     
nav_pill.dart +0/-46   
radial_heatmap.dart +0/-164 
recap_card.dart +5/-120 
kit.dart +0/-21   
os_icons.dart +3/-0     
today_screen.dart +9/-5     
workouts_screen.dart +88/-3   
absent_not_zero_test.dart +7/-8     
ai_briefing_test.dart +5/-5     
core_screens_test.dart +1/-1     
design_redesign_test.dart +39/-77 
design_system_test.dart +32/-2   
ui_kit_new_widgets_test.dart +0/-19   
workout_sleep_redesign_test.dart +6/-2     

Summary by CodeRabbit

Summary by CodeRabbit

  • New Features
    • Record workout routes using location only while workouts are active (while-in-use), including during screen lock/app switching.
    • Keep the screen awake during active workouts.
    • New feed/story preview flow for sharing workout cards, including no-route workouts.
  • Bug Fixes
    • Prevented duplicate milestone announcements; improved live workout pace timing based on moving time.
    • Strengthened reliability across live/finish transitions and map/metric rendering.
  • UI/UX Improvements
    • Refreshed live session layout, map/heart switching behavior, and redesigned orbit score/status display.
  • Documentation
    • Updated privacy policy with route recording, local-only storage, sharing, and deletion behavior details.

The gallery had drifted into a catalogue of things nothing used. Scanned
every public type in ui/design + ui/kit for call sites outside its own
file and the gallery, then removed what had none:

  RadialHeatmap, RecapCard, AreaSpark, DotMatrix, CalendarHeatmap,
  StatTile, BaselineProgress, NightCard, NavPillAction, StateChips,
  OrbitScore's satellite layer, and the dead TimelineScreen wrapper
  (Journey embeds TimelineContent and loads its own bundle).

Kept ArcGaugePainter/SkelBox/RouteMapScreen/RouteZoneLegend/MetricInfo —
they scan as unused but are consumed inside their own files.

Where a deleted component carried a real invariant, the test moved
rather than went: RecapCard's "a missing day holds its slot instead of
sliding the week left" now runs against MiniBars, whose invariant it
actually was.

Also: the Today readiness ring's status word is now a StateChipView pill
(Push / Focus / Recover) instead of loose text. Still derived from the
SAME readinessBand() cuts the AI briefing uses, so the ring and the
briefing cannot disagree. Presentation only — no kAlgoVersion bump.

ToggleChip had 5 live call sites and no gallery section at all; it has
one now. The gallery covers what ships, in both directions.

-1060 lines.
Five independent ways a workout could die, found while chasing "the app
closes in the middle" reports. Each is separately sufficient:

1. Android FGS type silently stripped. EdgeTrackingService.start(Context)
   builds its Intent with no EXTRA_LOCATION, defaulting to false — and
   CompanionBridge.onDeviceAppeared calls it whenever the band re-enters
   BLE range, which happens routinely mid-run from arm-swing dropouts.
   That re-startForeground'd as connectedDevice only, dropping `location`
   from a live session. The extra is now tri-state: present => that's the
   mode and it latches, absent => inherit what the live session asked for.
   The Dart-side sticky flag could never protect this; these callers never
   go through Dart.

2. Heavy derivation fired mid-ride. The foreground/background gate is
   INVERTED for this case: phone on the bars with the screen awake means
   the app IS foreground, so an isolate spawn (roughly doubling peak heap)
   landed at the worst possible moment, competing with GPS, the live map
   and the BLE drain. New DeriveScheduler.setWorkoutActive gate; a workout
   is minutes long and derives at the end anyway.

3. The screen slept, with no wakelock anywhere. Held now via the existing
   method channels — FLAG_KEEP_SCREEN_ON / isIdleTimerDisabled, both
   window-scoped, no new dependency. Released on every teardown path.

4. After a process restart, _reconcileOrphanedLiveWorkout restored the
   timer, calories and strain but never restarted route tracking — the
   map was silently dead for the rest of the session and you only found
   out at the finish screen.

5. notifyListeners() after dispose. Re-arming route tracking exposed the
   hazard dispose()'s own comment already warns about for timers: an
   in-flight await cannot be cancelled. Added a _disposed guard rather
   than weakening the test that caught it.

Also caps the decoded-image cache at 40 MiB. Flutter's 100 MiB default is
sized for a photo feed; retina map tiles are ~1 MiB decoded each, so a
long ride could sit on ~100 MiB of tile bitmaps on top of the persistent
pre-warmed engine, which is LMK/jetsam territory on a 3-4 GB device.

Worth knowing: telemetry is opt-in and defaults OFF, so none of this
necessarily produced a crash report. Worth querying Crashlytics for
jank_watchdog on LiveSessionScreen from consenting users.
REVIEW THIS ONE ON ITS OWN — it changes a user-facing privacy promise.

Without the `location` UIBackgroundMode, iOS suspends the process within
seconds of a screen lock or an app switch. The fix stream dies, the route
is lost, and a suspended app is first in line for jetsam. That was the
single largest cause of "the app closed mid-ride". The previous v1 stance
— while-in-use only plus a "Keep the screen on to map your route" hint in
the UI — was the architecture admitting the gap in UI copy, and it could
not survive a real 40-minute workout.

Scope is deliberately tight, and each of these was verified rather than
assumed:

  - Authorization stays WHEN-IN-USE. geolocator_apple's PermissionHandler
    is an if/else if: because we ship NSLocationWhenInUseUsageDescription
    it calls requestWhenInUseAuthorization and the Always branch is
    unreachable. We never ask for always-on location.
  - Session-scoped. _settings() has exactly one caller (stream()), which
    has exactly one caller (_maybeStartRouteTracking), torn down on every
    workout-end path.
  - showBackgroundLocationIndicator is ON, so the blue pill is visible the
    entire time we read location in the background.
  - Routes still go only to the on-device workout_route table. This
    changes WHEN we can read GPS, not where any of it goes.

The old usage string said "OpenStrap does not track your location in the
background." That becomes false the moment this ships, so it could not
stay. Both strings rewritten to describe what actually happens.

Both privacy documents also gain a "Location and workout routes" section
— neither mentioned location AT ALL, which was already a gap since the
app has recorded routes for a while. The claim that the AI Coach cannot
read route data is literal: coach_db derives its allowed root-page set by
EXPLAINing the permitted views, so workout_route is unreachable at the
btree level on a read-only handle, not merely absent from a name list.

No App Store / Play privacy-label change is needed: Apple's definition of
"collect" is transmission off device, and data processed only on device
is explicitly excluded. Android uses FOREGROUND_SERVICE_LOCATION, not
ACCESS_BACKGROUND_LOCATION, so no Play background-location declaration is
triggered either. App Review (guideline 2.5.4) is the thing to satisfy,
via the purpose strings above.
… bugs

The overlapping elements were structural, not styling. The screen was one
flat Stack of absolutely-positioned layers with no layout relationship
between them, so collisions were guaranteed on some device: the map's
re-centre button was pinned `bottom: 96` while the control panel is far
taller than that, so it rendered UNDERNEATH it; the centred recording pill
ran under the 44 px map toggle; the fixed 270 px core had nothing stopping
it colliding with the clock above and the panel below on a short phone.

Now a bounded hero and a metric sheet as SIBLINGS in a Column — overlap is
impossible rather than merely unlikely. Things that genuinely float are
Positioned inside the hero's own Stack, anchored to the hero's edges. The
ring takes its size from a LayoutBuilder instead of a hard 270.

Sheet follows the hierarchy every production run/ride app converges on:
one primary figure readable from a bar mount, a zone-tinted HR pill, three
evenly-weighted secondary stats. The old panel gave six stats identical
weight and tagged every one with the SAME generic pulse icon, so nothing
read first and the icons carried no information.

Bugs fixed alongside:

  - Pace read "40:32 /km" after barely moving: average pace divided
    distance by ELAPSED time, so every second spent standing still made
    it worse. Moving time only now; "—" when there is none. The test
    reproduces the exact reported number.
  - The 5-minute confetti re-fired on every return to the screen — the
    fired-milestone set lived on the screen's State, which is disposed
    and rebuilt on navigation. It belongs to the workout, so the workout
    holds it now.
  - The map greyed out past a zoom: TileLayer.maxZoom means "above this,
    draw NOTHING" (its docs say leave it infinite); maxNativeZoom is the
    one that describes a tile source and scales instead. Swapped. Also
    enabled retinaMode — the URL carried the {r} placeholder but the flag
    was never set, so we fetched standard-res tiles and upscaled them on
    every high-DPI device.
  - Zone colours were invisible. Measured, not eyeballed: the ramp
    resolved the ACTIVE palette while this screen is always dark, and Z0
    mapped to `cool` — a SURFACE token — measuring 1.03:1 against
    nightAlt. Z0 is the RESTING zone, so that is what is on screen at the
    start of every workout. New zoneOnDark ramp, Z0 at 9.76:1, guarded by
    a test asserting all six clear 3:1.
  - Label text used Colors.white30 (2.72:1) where small text needs 4.5:1.
    onNight/onNightSoft already existed and already passed — the screen
    just wasn't using them. Added the missing onNightMuted step so there
    is a token for every role and no reason to reach for a raw whiteNN.

The layout test asserts against the BOTTOM-most hero element. An earlier
version asserted on the clock and passed with the bug deliberately
reintroduced; this one fails by 15.5 px, verified both ways.
Sharing rasterised the whole finish card — header, route thumbnail, strain
gauge, peak/avg/kcal/steps, time-in-zones, the HR-recovery curve and any
PR badges — into one tall PNG and handed it straight to the OS sheet. A
screenshot of a dashboard, and you never saw it before it hit the
composer.

The card is now composed for the job: map full-bleed owning the frame, one
headline figure, three supporting stats, nothing else. Post (4:5) and
Story (9:16). The scrim is bottom-weighted at 52% height on purpose — a
full-height gradient greys the whole map out, which is what makes these
look muddy. Indoor workouts get the same composition with a zone-washed
backdrop rather than a second design.

Preview before send: the card is on screen at real proportions with a
format switcher. Better UX, and it removes a real failure mode —
capturing a map that was never on screen races tile loading and yields
half-blank images.

Share is the primary action now; "Full breakdown" is a quiet link. Also
dropped the canned "My OpenStrap workout" caption, which is exactly the
filler that makes a share feel automated.

Reachable from the workout DETAIL screen too — it was finish-screen only,
so leaving that screen made a workout unshareable forever. Both go through
buildWorkoutShareData so the same run cannot produce two different cards;
there is a test asserting exactly that. A live workout is not shareable
(no final numbers yet).

Includes a real release-only bug I shipped and had to fix: the preview
called RenderRepaintBoundary.debugNeedsPaint, which is

    bool get debugNeedsPaint {
      late bool result;
      assert(() { result = _needsPaint; return true; }());
      return result;
    }

Asserts are stripped in release/profile, so reading it throws
LateInitializationError and sharing worked in debug while failing on every
real build. Nothing catches this: the analyzer is happy and the whole test
suite runs in debug. test/no_debug_only_apis_test.dart now greps lib/ for
every getter in the Flutter SDK with that shape — the denylist was
derived, not guessed, and the regeneration command is in the file.

New OsIcon.share maps to FluentIcons.share_24_regular from a pack already
imported; goes through the OsIcon seam like every other icon.
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@abdulsaheel, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 8 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cb7def4b-a534-4e34-ae86-582b8442e2ec

📥 Commits

Reviewing files that changed from the base of the PR and between 1e8cac2 and 78cd72a.

📒 Files selected for processing (2)
  • lib/gps/screen_wake.dart
  • test/workout_reliability_test.dart
📝 Walkthrough

Walkthrough

This PR updates live workout lifecycle handling, location and screen-wake behavior, session layouts, route maps, workout sharing, design-system components, timeline presentation, privacy documentation, and regression/widget coverage.

Changes

Workout lifecycle and platform controls

Layer / File(s) Summary
Workout lifecycle and platform session controls
android/app/src/main/..., ios/Runner/..., lib/gps/..., lib/state/app_state.dart, lib/compute/..., PRIVACY.md, docs/privacy.html, test/workout_reliability_test.dart
Live workouts now hold derivation work, manage screen wake state, preserve location-session state, support iOS background route updates, guard disposed async state, and document local-only workout routes and while-in-use location behavior.

Live session layout and rendering

Layer / File(s) Summary
Live session layout, map rendering, and visual performance
lib/ui/activity/live_session_screen.dart, lib/ui/kit/route_map.dart, lib/theme/tokens.dart, lib/main.dart, test/live_session_layout_test.dart, test/zone_contrast_test.dart
The live screen uses separate hero and metric regions, moving-time pace, session-level milestone deduplication, cached map geometry, revised zone colors, bounded image caching, and localized finish-card animations.

Workout sharing

Layer / File(s) Summary
Workout share composition and entry points
lib/ui/activity/workout_share_card.dart, lib/ui/workouts/workouts_screen.dart, lib/ui/activity/live_session_screen.dart, test/workout_share_card_test.dart
Workout share data, feed/story cards, preview capture, temporary PNG sharing, and conditional workout-detail sharing are added and tested.

Design-system redesign

Layer / File(s) Summary
Design-system and timeline component redesign
lib/ui/design/*, lib/ui/kit/*, lib/ui/timeline/timeline_screen.dart, lib/ui/today/today_screen.dart, test/design_redesign_test.dart
OrbitScore and chip APIs are redesigned, obsolete chart/card widgets are removed, timeline rendering accepts preloaded data, and readiness labels change to Push/Focus/Recover.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AppState
  participant ScreenWake
  participant DeriveScheduler
  participant LiveSessionScreen
  participant WorkoutSharePreviewScreen
  AppState->>ScreenWake: enable during workout
  AppState->>DeriveScheduler: hold derivation during workout
  LiveSessionScreen->>WorkoutSharePreviewScreen: open composed share preview
  WorkoutSharePreviewScreen->>WorkoutSharePreviewScreen: capture card and share PNG
  AppState->>ScreenWake: release during teardown
  AppState->>DeriveScheduler: resume derivation
Loading

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • OpenStrap/edge#121: Both PRs modify the Today orbit hero and readiness-display logic.
  • OpenStrap/edge#146: Both changes update readiness-band labels and their Today/AI briefing mapping.
  • OpenStrap/edge#150: Both changes modify live-workout lifecycle handling in AppState.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main themes of the changeset: live screen rebuild, route recording, and share card overhaul.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/live-activity-and-share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 78cd72a)

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

UTC Day Label

_shareDate formats the date using t.day, t.month, and t.year directly from the DateTime passed in. If the caller passes DateTime.now() or a UTC epoch converted to a DateTime without .toLocal(), the day label will be wrong for users in timezones behind UTC (e.g., a workout finished at 00:30 UTC on the 15th shows as "15" for a user in UTC-5 who finished at 23:30 on the 14th). AGENTS.md §3.7 requires local labels via day_label.dart helpers. The share card's subtitle is cosmetic rather than a stored key, but it is a user-facing date claim and the invariant applies.

String _shareDate(DateTime t) =>
    '${t.day} ${_shareMonths[t.month - 1]} ${t.year}';
Strain shown without data guard

In buildWorkoutShareData, strain.toStringAsFixed(1) is always rendered as a supporting stat regardless of whether the value is meaningful. A session that ended immediately (e.g., accidental start/stop) or whose derivation has not yet run will show "0.0" as Strain rather than "—". AGENTS.md §3.3 requires absent input to produce null/"—", not a fabricated zero. The avgHr path correctly guards with avgHr != null && avgHr > 0, but strain and calories have no equivalent guard.

    (strain.toStringAsFixed(1), 'Strain'),
  ];
} else {
  heroValue = _shareDuration(duration);
  heroUnit = '';
  stats = [
    (strain.toStringAsFixed(1), 'Strain'),
    ('$calories', 'Kcal'),
ScreenWake not released on resume-then-immediate-end

In the resume path (around line 3434-3436), ScreenWake.enable() and _deriveScheduler.setWorkoutActive(true) are called after unawaited(_maybeStartRouteTracking(...)). If _maybeStartRouteTracking throws synchronously before those lines execute, or if the session is ended by another code path between the unawaited call and the ScreenWake.enable() line, the teardown paths (which call ScreenWake.release() and setWorkoutActive(false)) will have already run before the enable, leaving the wake lock permanently held. The ScreenWake.enable() should be called before the unawaited to match the pattern in the normal start path, or the enable/disable pair should be in a try/finally. AGENTS.md §4.3 flags exactly this shape of sticky boolean latch.

unawaited(_maybeStartRouteTracking(id, activeWorkout!.type));
_deriveScheduler.setWorkoutActive(true);
ScreenWake.enable();

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 78cd72a

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Context used after await without mounted guard

context is used after await without a mounted guard (AGENTS.md §4.5). If the widget
is disposed while the navigation push is in flight, Navigator.of(context) will
throw. Add a mounted check before the await call, or capture the navigator before
awaiting.

lib/ui/workouts/workouts_screen.dart [933-940]

 Future<void> _share() async {
   final data = _shareData.value;
   if (data == null) return;
-  await Navigator.of(context).push(
+  final nav = Navigator.of(context);
+  await nav.push(
     themedRoute((_) => WorkoutSharePreviewScreen(data: data),
         name: 'WorkoutSharePreviewScreen'),
   );
 }
Suggestion importance[1-10]: 7

__

Why: Using context after an await without a mounted check is a real bug that can cause exceptions if the widget is disposed during navigation. Capturing Navigator.of(context) before the await is the correct fix and the improved code accurately reflects this pattern.

Medium
Result returned before UI-thread flag change completes

result.success(true) is called immediately after runOnUiThread, before the UI thread
has actually executed the flag change. If the UI thread is busy, the Dart side
receives true (success) and ScreenWake._on is latched to true, but the flag has not
yet been applied — so a rapid release() call that follows will see _on == true, call
_apply(false), and the platform will clear a flag that was never set, leaving the
screen potentially un-held. Call result.success(true) inside the runOnUiThread
lambda, after the flag operation completes.

android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt [79-98]

 "keepAwake" -> {
     val on = call.argument<Boolean>("on") == true
     val activity = CompanionBridge.currentActivity
     if (activity == null) {
         result.success(false)
     } else {
         activity.runOnUiThread {
             if (on) {
                 activity.window.addFlags(
                     WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
                 )
             } else {
                 activity.window.clearFlags(
                     WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
                 )
             }
+            result.success(true)
         }
-        result.success(true)
     }
 }
Suggestion importance[1-10]: 7

__

Why: Calling result.success(true) outside the runOnUiThread lambda means the Dart side receives success before the flag is actually applied, which can cause a race condition with rapid enable/release calls. Moving result.success(true) inside the lambda is the correct fix and the improved code accurately reflects this change.

Medium
Move palette-change rebuild into didChangeDependencies

_rebuildIfNeeded() is called inside build() to catch palette changes, but it mutates
_points, _glow, and _crisp without calling setState. In a StatefulWidget, mutating
state fields outside setState during build is not guaranteed to trigger a repaint on
the next frame, and it bypasses Flutter's dirty-marking mechanism. The palette check
should instead be done in didChangeDependencies (which fires on InheritedWidget
changes) or the mutation should be wrapped in setState.

lib/ui/kit/route_map.dart [274]

-_rebuildIfNeeded();
-// Drop any non-finite GPS coordinate (a bad fix can carry NaN/Inf lat/lng).
+@override
+void didChangeDependencies() {
+  super.didChangeDependencies();
+  _rebuildIfNeeded();
+}
Suggestion importance[1-10]: 6

__

Why: The suggestion correctly identifies that mutating state fields (_points, _glow, _crisp) inside build() without setState is problematic. However, AppColors.active appears to be a global static rather than an InheritedWidget, so didChangeDependencies wouldn't fire on its change either — the suggestion's proposed fix may not fully solve the problem, but the concern about mutating state in build is valid.

Low
Absent start timestamp fabricates current time instead of abstaining

When startTs is null or zero, the when field falls back to DateTime.now(),
fabricating a timestamp instead of abstaining — violating the "never fabricate a
metric" invariant (AGENTS.md §3.3). A share card with a wrong date is misleading;
the share button should simply not appear if the start time is unknown. Set
widget.shareData.value = null when startTs is absent rather than substituting the
current time.

lib/ui/workouts/workouts_screen.dart [1075-1088]

+if (startTs == null || startTs <= 0) {
+  widget.shareData.value = null;
+  return;
+}
 widget.shareData.value = buildWorkoutShareData(
   units: context.read<UnitsController>(),
   type: (d['type'] as String?) ?? '',
   duration: Duration(seconds: durationSec.round()),
-  when: startTs != null && startTs > 0
-      ? DateTime.fromMillisecondsSinceEpoch(startTs * 1000).toLocal()
-      : DateTime.now(),
+  when: DateTime.fromMillisecondsSinceEpoch(startTs * 1000).toLocal(),
   maxHr: context.read<AppState>().maxHr,
   strain: (d['strain'] as num?)?.toDouble() ?? 0,
   calories: (d['calories'] as num?)?.toInt() ?? 0,
   route: _route,
   avgHr: (d['avg_hr'] as num?)?.toInt(),
 );
Suggestion importance[1-10]: 6

__

Why: The fallback to DateTime.now() when startTs is null or zero fabricates a timestamp for the share card, which is misleading. Returning null instead would prevent showing a share button with incorrect data. The suggestion is valid and the improved code accurately reflects the fix.

Low
Use local time for day label formatting

_shareDate formats the DateTime using its fields directly, which will use whatever
timezone the DateTime carries. If when is passed as a UTC DateTime (e.g. from a
database epoch), the day, month, and year will be UTC values, violating the
invariant that day labels must be local (AGENTS.md §3 rule 7). Convert to local
before formatting.

lib/ui/activity/workout_share_card.dart [609-610]

-String _shareDate(DateTime t) =>
-    '${t.day} ${_shareMonths[t.month - 1]} ${t.year}';
+String _shareDate(DateTime t) {
+  final local = t.toLocal();
+  return '${local.day} ${_shareMonths[local.month - 1]} ${local.year}';
+}
Suggestion importance[1-10]: 5

__

Why: Valid concern — if when is a UTC DateTime, the day/month/year fields will be UTC values, potentially showing the wrong date. Converting to local time before formatting is a safe defensive fix.

Low
General
Prevent silent milestone drop when workout is transiently null

When fired is null (i.e. _app or activeWorkout is null), the method silently returns
without firing the milestone. This is correct. However, fired.add(key) returns true
when the key is newly added and false when it already exists — so !fired.add(key)
returns early when the key is already present, which is the intended dedup. But the
condition fired == null || !fired.add(key) means a null fired set also silently
drops the milestone rather than firing it. If activeWorkout is transiently null
during a reconnect, milestones will be permanently lost for that session. Consider
only skipping when the key is already present, and firing (with a local fallback
set) when fired is null.

lib/ui/activity/live_session_screen.dart [274-279]

 void _milestone(String key, String big, String sub, Color c) {
     final fired = _app?.activeWorkout?.firedMilestones;
-    if (fired == null || !fired.add(key)) return;
+    // If we have a durable set, use it for dedup. If it's transiently
+    // unavailable, fall back to the screen-local set so we neither
+    // re-fire nor silently drop the milestone.
+    final dedup = fired ?? _localMilestones;
+    if (!dedup.add(key)) return;
     _fireCallout(big, sub);
     _fireConfetti(c);
     HapticFeedback.mediumImpact();
Suggestion importance[1-10]: 5

__

Why: The suggestion raises a valid concern: if _app?.activeWorkout is transiently null, milestones are silently dropped rather than fired. However, the improved code references _localMilestones which is not defined in the PR (the old _milestones field was removed), so the suggestion would require adding back a local fallback set, making it a moderate-impact but incomplete fix.

Low
Sync animation controllers on initial mount

_syncDecorativeAnimations is called from _onRoutePathTick and from the _ViewToggle
callback, but NOT from _userToggledMap path in the old inline setState that still
exists in the viewToggle builder. More critically, it is never called when the
widget first builds with _showMap already true (e.g. if the route path is non-empty
at mount time), so the controllers keep ticking from initState even though the map
is the initial view. Call _syncDecorativeAnimations() at the end of initState (after
the controllers are started) to establish the correct initial state.

lib/ui/activity/live_session_screen.dart [159-168]

-void _syncDecorativeAnimations() {
-    final onMap = _showMap;
-    if (onMap) {
-      if (_beat.isAnimating) _beat.stop();
-      if (_fx.isAnimating) _fx.stop();
-    } else {
-      if (!_beat.isAnimating) _beat.repeat(reverse: true);
-      if (!_fx.isAnimating) _fx.repeat();
-    }
-  }
+@override
+void initState() {
+  super.initState();
+  // ... existing initState setup ...
+  // Establish correct animation state for the initial view.
+  _syncDecorativeAnimations();
+}
Suggestion importance[1-10]: 5

__

Why: The concern is valid — if _showMap is true at mount time (e.g. route path already non-empty), the decorative animation controllers will keep ticking unnecessarily. However, the improved_code is a stub that doesn't show the actual initState context, making it hard to verify correctness. The fix itself (calling _syncDecorativeAnimations() at the end of initState) is straightforward and would prevent unnecessary battery drain on initial load.

Low
Guard context use after async gap

context.read() is called before the await in Navigator.of(context).push(...), so the
read itself is safe. However, Navigator.of(context) is used after the await without
a mounted guard — the widget could be disposed between buildWorkoutShareData
returning and the push executing. The existing if (!mounted) return; guard is placed
correctly before the await, but if buildWorkoutShareData itself is async and
suspends, the guard needs to be immediately before the Navigator call.
Move the if
(!mounted) return; guard to just before Navigator.of(context).push(...), after any
async work in buildWorkoutShareData completes.

lib/ui/activity/live_session_screen.dart [1370-1389]

 Future<void> _share() async {
     final s = widget.snapshot;
     final d = _detail;
+    final units = context.read<UnitsController>();
     final data = buildWorkoutShareData(
-      units: context.read<UnitsController>(),
+      units: units,
       type: s.type,
       duration: s.duration,
       when: DateTime.now(),
-      ...
+      maxHr: _maxHr,
+      strain: (d?['strain'] as num?)?.toDouble() ?? s.strain,
+      calories: (d?['calories'] as num?)?.toInt() ?? s.calories.round(),
+      route: _route,
+      avgHr: (d?['avg_hr'] as num?)?.toInt(),
     );
     if (!mounted) return;
     await Navigator.of(context).push(
       themedRoute((_) => WorkoutSharePreviewScreen(data: data),
           name: 'WorkoutSharePreviewScreen'),
     );
   }
Suggestion importance[1-10]: 3

__

Why: The if (!mounted) return; guard is already placed correctly before the await Navigator.of(context).push(...) call. The suggestion's concern about buildWorkoutShareData being async is not evidenced in the diff — it appears to be a synchronous function. The improved_code is essentially the same as the existing code with only a minor refactor of extracting units to a local variable, which doesn't address a real bug.

Low
Guard context access after async file write

context is used after the await file.writeAsBytes(...) call without a mounted guard
immediately before context.findRenderObject(). The mounted check above guards the
Share.shareXFiles call but context.findRenderObject() itself is also a BuildContext
access that can throw if the widget has been disposed between the write and the
render-object lookup. The mounted check should be placed before the findRenderObject
call.

lib/ui/activity/workout_share_card.dart [384-392]

 if (!mounted) return;
 final box = context.findRenderObject() as RenderBox?;
 final origin = (box != null && box.hasSize)
     ? (box.localToGlobal(Offset.zero) & box.size)
     : null;
-// No caption text: the image carries everything, and a canned
-// "My OpenStrap workout" string is exactly the kind of filler that makes
-// a share feel automated.
+if (!mounted) return;
 await Share.shareXFiles([XFile(file.path)], sharePositionOrigin: origin);
Suggestion importance[1-10]: 3

__

Why: The existing code already has a mounted check before findRenderObject() at line 384. The suggestion adds a second mounted check before Share.shareXFiles, but the improved_code is nearly identical to the existing_code — the only addition is a redundant extra if (!mounted) return; that provides minimal safety benefit given the check already exists just above.

Low

Previous suggestions

Suggestions up to commit 1e8cac2
CategorySuggestion                                                                                                                                    Impact
Possible issue
Capture render object before async gap

context is used after await file.writeAsBytes(...) — this is a
context/Provider-after-await pattern flagged in AGENTS.md §4.5. The mounted guard
immediately before only prevents a crash if the widget is fully unmounted; it does
not protect against context being stale or findRenderObject returning a detached
render object after the async gap. findRenderObject should be called before the
first await and stored, or at minimum the RenderBox should be captured before the
async gap.

lib/ui/activity/workout_share_card.dart [384-392]

-if (!mounted) return;
+// Capture the render box BEFORE any await so context is never used after
+// an async gap (AGENTS.md §4.5).
 final box = context.findRenderObject() as RenderBox?;
 final origin = (box != null && box.hasSize)
     ? (box.localToGlobal(Offset.zero) & box.size)
     : null;
+
+await WidgetsBinding.instance.endOfFrame;
+if (!mounted) return;
+
+final pixelRatio = 1080 / WorkoutShareCard.kWidth;
+final ui.Image image = await boundary.toImage(pixelRatio: pixelRatio);
+final ByteData? bytes =
+    await image.toByteData(format: ui.ImageByteFormat.png);
+image.dispose();
+if (bytes == null) throw StateError('Failed to encode image');
+
+final dir = await getTemporaryDirectory();
+final file = File('${dir.path}/openstrap_share.png');
+await file.writeAsBytes(bytes.buffer.asUint8List());
+
+if (!mounted) return;
 await Share.shareXFiles([XFile(file.path)], sharePositionOrigin: origin);
Suggestion importance[1-10]: 7

__

Why: This is a legitimate Flutter best practice — capturing context-dependent objects before async gaps avoids using a potentially stale context. The improved_code correctly moves findRenderObject before the first await, which is the right fix for this pattern.

Medium
Provider read after await without mounted guard

_publishShareData is called from setState inside initState's async callback, but it
calls context.read() — a Provider lookup — after an await. If the widget is
unmounted between the DB fetch and this call, context is stale and the lookup
throws. Guard with if (!mounted) return; before the buildWorkoutShareData call,
consistent with the existing mounted guard in the error path.

lib/ui/workouts/workouts_screen.dart [1072-1087]

 final durationSec = (startTs != null && endTs != null && endTs > startTs)
     ? endTs - startTs
     : ((d['duration_min'] as num?)?.toDouble() ?? 0) * 60;
+if (!mounted) return;
 widget.shareData.value = buildWorkoutShareData(
   units: context.read<UnitsController>(),
   ...
   when: startTs != null && startTs > 0
       ? DateTime.fromMillisecondsSinceEpoch(startTs * 1000).toLocal()
       : DateTime.now(),
Suggestion importance[1-10]: 7

__

Why: This is a real and common Flutter bug — calling context.read<>() after an await without a mounted check can throw if the widget is unmounted. The existing code already has a mounted guard in the error path, making this omission inconsistent and potentially crashable.

Medium
Day label must use local time, not UTC

_shareDate formats using the DateTime passed in directly, but when is constructed by
the caller and may be in UTC. If the caller passes DateTime.now() or a UTC
timestamp, the day label will be wrong for users in timezones behind UTC (e.g. a
workout finished at 23:30 UTC on Jul 26 shows "27 Jul" instead of "26 Jul"). Per
AGENTS.md §3.7, day labels must use local time — use t.toLocal() before extracting
fields.

lib/ui/activity/workout_share_card.dart [609-610]

-String _shareDate(DateTime t) =>
-    '${t.day} ${_shareMonths[t.month - 1]} ${t.year}';
+String _shareDate(DateTime t) {
+  final local = t.toLocal();
+  return '${local.day} ${_shareMonths[local.month - 1]} ${local.year}';
+}
Suggestion importance[1-10]: 6

__

Why: The suggestion is valid — using t.toLocal() before extracting day/month/year fields prevents incorrect date labels for users in timezones behind UTC. However, the impact depends on how when is constructed by callers; if callers already pass local DateTime objects, this is a no-op but still a defensive improvement.

Low
iOS wake-flag latched without checking channel result

On iOS the result of invokeMethod is not checked before latching _on = on, unlike
the Android path which guards on ok != true. If the iOS channel call throws or
returns null the flag is still updated, causing the same "assumed success" bug the
Android path explicitly avoids — a failed release() leaves _on = false while the
idle timer is still disabled, and every subsequent release() short-circuits on the
if (on == _on) guard.

lib/gps/screen_wake.dart [71-74]

 } else if (_isIOS) {
-    await _ios.invokeMethod<bool>('keepAwake', {'on': on});
+    final ok = await _ios.invokeMethod<bool>('keepAwake', {'on': on});
+    if (ok != true) return;
 }
 _on = on;
Suggestion importance[1-10]: 6

__

Why: The Android path explicitly guards on ok != true before updating _on, but the iOS path unconditionally updates _on regardless of the channel result. This asymmetry means a failed iOS release() could leave the flag in an incorrect state, preventing future retries — the improved_code correctly mirrors the Android pattern.

Low
Capture Navigator before async gap to avoid stale context

context.read() is called before the await Navigator.of(context).push(...), so it is
safe here. However, Navigator.of(context) is used after the await on
buildWorkoutShareData — if buildWorkoutShareData is async and does any real work,
context may be stale. The if (!mounted) return guard is placed correctly before the
await Navigator.push, but Navigator.of(context) is captured inside the same
expression as the await, so if the widget is unmounted during buildWorkoutShareData
the navigator call still executes. Capture Navigator.of(context) before the async
gap to be safe.

lib/ui/activity/live_session_screen.dart [1370-1389]

 Future<void> _share() async {
   final s = widget.snapshot;
   final d = _detail;
+  final nav = Navigator.of(context);
+  final units = context.read<UnitsController>();
   final data = buildWorkoutShareData(
-    units: context.read<UnitsController>(),
+    units: units,
     type: s.type,
     duration: s.duration,
     when: DateTime.now(),
     maxHr: _maxHr,
     strain: (d?['strain'] as num?)?.toDouble() ?? s.strain,
     calories: (d?['calories'] as num?)?.toInt() ?? s.calories.round(),
     route: _route,
     avgHr: (d?['avg_hr'] as num?)?.toInt(),
   );
   if (!mounted) return;
-  await Navigator.of(context).push(
+  await nav.push(
     themedRoute((_) => WorkoutSharePreviewScreen(data: data),
         name: 'WorkoutSharePreviewScreen'),
   );
 }
Suggestion importance[1-10]: 5

__

Why: The suggestion correctly identifies that Navigator.of(context) is used after an async gap, which can cause issues if the widget is unmounted. However, looking at the code, buildWorkoutShareData appears to be synchronous (no await before it), so the if (!mounted) return guard is placed correctly before any actual async operation (Navigator.push). The risk is lower than described, but pre-capturing Navigator.of(context) is still a good practice.

Low
Absent timestamp fabricates value instead of abstaining

When startTs is absent the code falls back to DateTime.now(), fabricating a
timestamp for the share card rather than abstaining. This violates the "never
fabricate a metric" invariant (§3.3) — a share card with a wrong date is worse than
no share card. Set shareData.value = null when startTs is missing instead of
substituting the current time.

lib/ui/workouts/workouts_screen.dart [1079-1081]

 when: startTs != null && startTs > 0
     ? DateTime.fromMillisecondsSinceEpoch(startTs * 1000).toLocal()
-    : DateTime.now(),
+    : null,
Suggestion importance[1-10]: 4

__

Why: The suggestion raises a valid concern about fabricating a timestamp with DateTime.now() when startTs is absent. However, the improved_code sets when to null, which may not be valid if buildWorkoutShareData requires a non-nullable DateTime parameter — the suggestion doesn't account for the function signature, making it potentially incorrect as written.

Low
General
Use distance-over-moving-time for ride average speed

When speeds is non-empty but paceText is '—' (because route.movingSec == 0), the
pace stat is displayed as '—' for non-ride workouts. This is the correct abstaining
behavior per the project's honesty rule, so that path is fine. However, for rides
avgSpeed is computed as a simple mean of all instantaneous speed samples including
zero-speed points (stopped at lights, etc.), which will understate the true moving
average. The existing RouteTracker already tracks movingSeconds; use
route.distanceMeters / route.movingSec (converted via units.speed) for the ride
average instead of averaging raw speed samples, consistent with how pace is computed
for runs.

lib/ui/activity/live_session_screen.dart [1260-1265]

-final avgSpeed = speeds.isEmpty
-    ? null
-    : speeds.reduce((a, b) => a + b) / speeds.length;
-final third = isRide && avgSpeed != null
-    ? (units.speed(avgSpeed), 'AVG SPEED')
+final third = isRide && route.movingSec > 0
+    ? (units.speed(route.distanceMeters / route.movingSec), 'AVG SPEED')
     : (paceText, 'AVG PACE');
Suggestion importance[1-10]: 6

__

Why: The suggestion correctly identifies that averaging raw speed samples (including zero-speed points) understates the true moving average speed for rides. Using distanceMeters / movingSec is consistent with how pace is computed for runs and aligns with the PR's stated principle of using moving time for pace calculations.

Low
Sync animation controllers on initial state

_syncDecorativeAnimations is called from _onRoutePathTick and from the viewToggle
onChanged callback, but it is never called from initState or when _userToggledMap
first sets _showMap = true. If the map is already showing when the widget is first
built (e.g. GPS lock is immediate), the controllers are never stopped, so the
battery-burning vsync loop the comment describes is not actually fixed for the
initial state. Call _syncDecorativeAnimations() at the end of initState (after the
controllers are started) to cover this path.

lib/ui/activity/live_session_screen.dart [159-168]

 void _syncDecorativeAnimations() {
   final onMap = _showMap;
   if (onMap) {
     if (_beat.isAnimating) _beat.stop();
     if (_fx.isAnimating) _fx.stop();
   } else {
     if (!_beat.isAnimating) _beat.repeat(reverse: true);
     if (!_fx.isAnimating) _fx.repeat();
   }
 }
+// In initState, after controllers are started:
+// _syncDecorativeAnimations();
Suggestion importance[1-10]: 5

__

Why: The suggestion is valid — if _showMap is true at widget initialization (e.g., GPS lock is immediate), the decorative animation controllers would keep running unnecessarily. However, the improved_code doesn't actually show the fix applied (it only adds a comment), making it incomplete. The impact is real but the fix is straightforward.

Low
State mutation in build must go through setState

_rebuildIfNeeded() is called at the top of build() to catch palette changes, but it
mutates _points, _glow, and _crisp without calling setState. In a StatefulWidget,
mutating state fields outside setState during build is legal only because build
reads the new values immediately — but if a palette change triggers a rebuild from
outside (e.g. ThemeController notifying listeners), the widget rebuilds,
_rebuildIfNeeded updates the fields, yet no markNeedsBuild is issued for any
dependent, so the polyline cache update is invisible to flutter_map's own subtree
until the next unrelated rebuild. Wrap the mutation in setState when called from
build.

lib/ui/kit/route_map.dart [274]

-_rebuildIfNeeded();
-// Drop any non-finite GPS coordinate (a bad fix can carry NaN/Inf lat/lng).
+// Also checked here, not only in didUpdateWidget: the palette is a global
+// static, so a theme switch that leaves this widget instance untouched
+// would otherwise never invalidate the cache. Three identity comparisons
+// when nothing changed.
+if (!identical(_builtPalette, AppColors.active)) {
+  WidgetsBinding.instance.addPostFrameCallback((_) {
+    if (mounted) setState(_rebuildIfNeeded);
+  });
+}
Suggestion importance[1-10]: 4

__

Why: The concern about mutating state fields in build without setState is partially valid, but the improved_code uses addPostFrameCallback which changes the behavior significantly and may introduce a one-frame lag. Additionally, calling setState from within build itself would cause an infinite loop, so the suggestion's framing is somewhat misleading. The actual risk is low since build reads the updated fields immediately in the same frame.

Low
Suggestions up to commit ea5bae6
CategorySuggestion                                                                                                                                    Impact
Possible issue
Guard Navigator use after async with mounted check

context.read() is called before the await Navigator.of(context).push(...), so the
read itself is safe. However, Navigator.of(context) is used after the await without
a mounted guard — if the widget is disposed while buildWorkoutShareData runs (e.g.
the user navigates away), this will throw "Looking up a deactivated widget's
ancestor". Per AGENTS.md §4.5, a mounted check is required after every await before
using context.

lib/ui/activity/live_session_screen.dart [1370-1389]

 Future<void> _share() async {
   final s = widget.snapshot;
   final d = _detail;
+  final units = context.read<UnitsController>();
   final data = buildWorkoutShareData(
-    units: context.read<UnitsController>(),
+    units: units,
     type: s.type,
     duration: s.duration,
     when: DateTime.now(),
     maxHr: _maxHr,
     strain: (d?['strain'] as num?)?.toDouble() ?? s.strain,
     calories: (d?['calories'] as num?)?.toInt() ?? s.calories.round(),
     route: _route,
     avgHr: (d?['avg_hr'] as num?)?.toInt(),
   );
   if (!mounted) return;
   await Navigator.of(context).push(
     themedRoute((_) => WorkoutSharePreviewScreen(data: data),
         name: 'WorkoutSharePreviewScreen'),
   );
 }
Suggestion importance[1-10]: 7

__

Why: The existing code already has if (!mounted) return; before Navigator.of(context).push(...), but buildWorkoutShareData could be async in the future. More importantly, the improved_code correctly extracts units before the async gap, which is a genuine improvement to avoid using context after a potential suspension point.

Medium
Avoid mutating cached state during build without setState

Calling _rebuildIfNeeded() inside build mutates state (_builtFrom, _points, _glow,
_crisp) without going through setState. In Flutter's framework, mutating state
outside setState during build is undefined behaviour — the framework may or may not
schedule a repaint, and in profile/release builds the dirty-check optimisations can
skip the next frame entirely. When a theme switch triggers a rebuild and
_rebuildIfNeeded updates the cached polylines, the new polylines are used for this
frame but the widget is not marked dirty for the next one, so the change may not
propagate. The palette change should instead be detected in didChangeDependencies
(which fires on InheritedWidget changes) and call setState there.

lib/ui/kit/route_map.dart [268-274]

+@override
+void didChangeDependencies() {
+  super.didChangeDependencies();
+  // Palette is a global static updated by the theme InheritedWidget;
+  // detect the change here so setState marks the widget dirty properly.
+  if (!identical(_builtPalette, AppColors.active)) {
+    setState(_rebuildIfNeeded);
+  }
+}
+
 @override
 Widget build(BuildContext context) {
-  // Also checked here, not only in didUpdateWidget: the palette is a global
-  // static, so a theme switch that leaves this widget instance untouched
-  // would otherwise never invalidate the cache. Three identity comparisons
-  // when nothing changed.
-  _rebuildIfNeeded();
Suggestion importance[1-10]: 7

__

Why: Mutating _builtFrom, _points, _glow, and _crisp inside build without setState is technically a framework violation and can cause subtle rendering issues. The suggestion to use didChangeDependencies with setState is architecturally sounder, though the current approach may work in practice since the mutation happens during the build pass itself.

Medium
Guard animation controller calls against disposed state

_syncDecorativeAnimations is called from _onRoutePathTick and from the viewToggle
onChanged callback, but NOT from _userToggledMap path inside initState or when the
map auto-shows on first route tick. More critically, if _beat or _fx are stopped
while the widget is disposed (e.g. during a hot-restart or navigation pop), calling
stop() or repeat() on a disposed controller will throw. The method should guard with
if (!mounted) return; at the top, matching the pattern already used in
_onRoutePathTick.

lib/ui/activity/live_session_screen.dart [159-168]

 void _syncDecorativeAnimations() {
+  if (!mounted) return;
   final onMap = _showMap;
   if (onMap) {
     if (_beat.isAnimating) _beat.stop();
     if (_fx.isAnimating) _fx.stop();
   } else {
     if (!_beat.isAnimating) _beat.repeat(reverse: true);
     if (!_fx.isAnimating) _fx.repeat();
   }
 }
Suggestion importance[1-10]: 6

__

Why: Adding a mounted guard is a valid defensive pattern, but AnimationController methods like stop() and repeat() are typically safe to call even after disposal in Flutter (they don't throw). The risk is real but lower than described; the fix is still a reasonable improvement.

Low
Use local time for the share card date label

_shareDate formats the DateTime it receives as-is, but when is passed from callers
that may supply a UTC timestamp (e.g. a session start stored as epoch). If when is
UTC, t.day/t.month/t.year are UTC fields and the date shown on the card can be one
day off for athletes in UTC+ timezones — exactly the UTC-vs-local bug class
documented in §4.8. The subtitle should use local fields.

lib/ui/activity/workout_share_card.dart [609-610]

-String _shareDate(DateTime t) =>
-    '${t.day} ${_shareMonths[t.month - 1]} ${t.year}';
+String _shareDate(DateTime t) {
+  final local = t.toLocal();
+  return '${local.day} ${_shareMonths[local.month - 1]} ${local.year}';
+}
Suggestion importance[1-10]: 6

__

Why: The suggestion correctly identifies a real UTC-vs-local timezone bug: if when is a UTC DateTime, t.day/t.month/t.year return UTC fields, which can show the wrong date for athletes in UTC+ timezones. The fix is straightforward and accurate.

Low
Missing mounted guard after async gap before Navigator push

Navigator.of(context) is used after await without a mounted check, which is a
recurring crash source in this codebase (§4.5). If the widget is disposed while the
share preview is being prepared, the Navigator call will throw. Add a mounted guard
before the push.

lib/ui/workouts/workouts_screen.dart [933-940]

 Future<void> _share() async {
   final data = _shareData.value;
   if (data == null) return;
+  if (!mounted) return;
   await Navigator.of(context).push(
     themedRoute((_) => WorkoutSharePreviewScreen(data: data),
         name: 'WorkoutSharePreviewScreen'),
   );
 }
Suggestion importance[1-10]: 6

__

Why: The _share() method uses Navigator.of(context) without a mounted check, but there is no actual await before the Navigator.of(context).push(...) call — _shareData.value is a synchronous read. The mounted check would be a best practice but the risk of a crash here is low since there's no async gap before the context use.

Low
Absent start timestamp fabricates a value instead of abstaining

When startTs is absent the code falls back to DateTime.now(), fabricating a
timestamp for the share card rather than abstaining — a direct violation of the
"never fabricate a metric" invariant (§3.3). If the start time is unknown the share
data should not be published at all, matching the same guard already applied for
status == 'live'.

lib/ui/workouts/workouts_screen.dart [1079-1081]

-final when: startTs != null && startTs > 0
-    ? DateTime.fromMillisecondsSinceEpoch(startTs * 1000).toLocal()
-    : DateTime.now(),
+if (startTs == null || startTs <= 0) {
+  widget.shareData.value = null;
+  return;
+}
+widget.shareData.value = buildWorkoutShareData(
+  units: context.read<UnitsController>(),
+  type: (d['type'] as String?) ?? '',
+  duration: Duration(seconds: durationSec.round()),
+  when: DateTime.fromMillisecondsSinceEpoch(startTs * 1000).toLocal(),
+  maxHr: context.read<AppState>().maxHr,
+  strain: (d['strain'] as num?)?.toDouble() ?? 0,
+  calories: (d['calories'] as num?)?.toInt() ?? 0,
+  route: _route,
+  avgHr: (d['avg_hr'] as num?)?.toInt(),
+);
Suggestion importance[1-10]: 5

__

Why: The suggestion raises a valid concern about fabricating a DateTime.now() fallback when startTs is unknown, which could produce misleading share cards. However, the severity depends on how common missing start_ts is in practice — completed workouts should always have it. The fix is reasonable but the impact is moderate.

Low
General
Guard context access after every await in share flow

context is used after the await file.writeAsBytes(...) call without a mounted guard
immediately before the context.findRenderObject() access. The mounted check two
lines above only guards the early-return path; context.findRenderObject() is then
called unconditionally. If the widget is unmounted between the mounted check and
findRenderObject, this dereferences a stale BuildContext — a known crash source in
this codebase (§4.5). The findRenderObject call and the subsequent Share.shareXFiles
should be inside the same mounted guard.

lib/ui/activity/workout_share_card.dart [384-392]

 if (!mounted) return;
 final box = context.findRenderObject() as RenderBox?;
 final origin = (box != null && box.hasSize)
     ? (box.localToGlobal(Offset.zero) & box.size)
     : null;
-// No caption text: the image carries everything, and a canned
-// "My OpenStrap workout" string is exactly the kind of filler that makes
-// a share feel automated.
+if (!mounted) return;
 await Share.shareXFiles([XFile(file.path)], sharePositionOrigin: origin);
Suggestion importance[1-10]: 4

__

Why: The suggestion adds an extra mounted check before Share.shareXFiles, but the existing mounted check just two lines above already guards context.findRenderObject(). The window between the existing mounted check and Share.shareXFiles is extremely narrow (no await in between), making this a very low-risk scenario. The improved_code accurately reflects the change but the impact is marginal.

Low
Boolean latch may not reset on failure path

_running is set to true here but the new re-check block above it returns early
without setting _running = false — however _running starts as false at this point in
the flow so that path is safe. The real risk is that if the code after this point
throws before the finally that clears _running, the flag stays latched (§4.3).
Verify there is a try/finally wrapping _running = true that unconditionally resets
it; if not, the scheduler wedges until restart.

lib/compute/derive_scheduler.dart [187-188]

 _running = true;
 await _refreshSnapshot();
+try {
+  // ... existing drain body ...
+} finally {
+  _running = false;
+  _arm();
+}
Suggestion importance[1-10]: 4

__

Why: The concern about _running not being reset on exception is valid in principle, but the suggestion asks to verify existing behavior rather than pointing to a confirmed bug in the PR diff. The improved_code is incomplete (placeholder comment) and doesn't reflect the actual existing drain body.

Low
Move per-point speed reduction off the UI isolate

When paceText is '—' (no moving time yet) and it is not a ride, the third stat
correctly shows '—'. However, for a ride with avgSpeed != null but speeds computed
from route.points on the finish screen, this iterates all GPS points on the UI
isolate — for an hour-long ride that can be thousands of points. Per AGENTS.md §3.10
and §4.4, heavy compute must not run on the UI isolate. The speed average should be
pre-computed in the route model or via Isolate.run, not inline in a build-time
method.

lib/ui/activity/live_session_screen.dart [1260-1265]

-final avgSpeed = speeds.isEmpty
+// Delegate the O(N) reduction off the UI isolate.
+final avgSpeedFuture = speeds.isEmpty
     ? null
-    : speeds.reduce((a, b) => a + b) / speeds.length;
+    : Isolate.run(() => speeds.reduce((a, b) => a + b) / speeds.length);
+// For the synchronous build path, use the pre-computed value from the route
+// model if available, falling back to null (shows '—') until ready.
+final avgSpeed = route.avgSpeedMps; // pre-computed field on RouteResult
 final third = isRide && avgSpeed != null
     ? (units.speed(avgSpeed), 'AVG SPEED')
     : (paceText, 'AVG PACE');
Suggestion importance[1-10]: 2

__

Why: The suggestion references route.avgSpeedMps as a pre-computed field that may not exist in the codebase, and the improved_code introduces Isolate.run in a synchronous build method which is architecturally incorrect. The concern about O(N) iteration is valid but the proposed fix is not accurately derived from the PR code.

Low
Suggestions up to commit 062884a
CategorySuggestion                                                                                                                                    Impact
Possible issue
Route polyline updates skip setState and never repaint

_rebuildIfNeeded mutates _points, _glow, and _crisp directly without calling
setState. When called from didUpdateWidget, Flutter will not schedule a repaint for
the new polylines, so the map continues to display the stale route until something
else triggers a rebuild. The mutation must be wrapped in setState when called after
initState.

lib/ui/kit/route_map.dart [188-194]

 void _rebuildIfNeeded() {
   if (identical(_builtFrom, widget.vertices)) return;
   _builtFrom = widget.vertices;
-  _points = [for (final v in widget.vertices) v.pos];
-  _glow = _polylines(glow: true);
-  _crisp = _polylines();
+  final points = [for (final v in widget.vertices) v.pos];
+  final glow = _polylines(glow: true);
+  final crisp = _polylines();
+  if (mounted) {
+    setState(() {
+      _points = points;
+      _glow = glow;
+      _crisp = crisp;
+    });
+  } else {
+    _points = points;
+    _glow = glow;
+    _crisp = crisp;
+  }
 }
Suggestion importance[1-10]: 8

__

Why: This is a real bug: _rebuildIfNeeded mutates _points, _glow, and _crisp directly without setState when called from didUpdateWidget, meaning Flutter won't schedule a repaint and the map will show stale polylines. The improved_code correctly wraps the mutations in setState while handling the initState case (not yet mounted) by assigning directly.

Medium
Boolean latch not reset on platform-channel failure path

_on is set to on before the platform call succeeds. If the channel throws, _on is
left in the new state even though the platform was never updated — a sticky boolean
latch with no reset on the failure path (§4.3). On a failure, a subsequent enable()
call will see _on == true and skip the channel, so the screen never actually wakes.
Reset _on to its previous value in the catch block.

lib/gps/screen_wake.dart [44-57]

 static Future<void> _set(bool on) async {
   if (on == _on) return;
   _on = on;
   try {
     if (Platform.isAndroid) {
       await _android.invokeMethod('keepAwake', {'on': on});
     } else if (Platform.isIOS) {
       await _ios.invokeMethod('keepAwake', {'on': on});
     }
   } catch (e) {
     // Never surface: losing the wake flag degrades to "screen sleeps".
     debugPrint('[screen-wake] ${on ? 'enable' : 'release'} failed: $e');
+    _on = !on; // reset so the next call retries the platform
   }
 }
Suggestion importance[1-10]: 7

__

Why: Setting _on = on before the platform call and not resetting it on failure leaves the latch in an inconsistent state, causing subsequent enable() calls to silently skip the channel. This is a real logic bug that could prevent the screen from staying awake during workouts, and the fix is accurate and minimal.

Medium
Capture context-dependent values before async suspension

context.read() and Navigator.of(context) are called across an await boundary —
buildWorkoutShareData is called synchronously here, but if it ever becomes async, or
if the mounted check is insufficient on some paths, context use after await will
crash. Per AGENTS.md §4.5, context and Provider must not be used after await.
Capture context.read() and Navigator.of(context) before any suspension point.

lib/ui/activity/live_session_screen.dart [1370-1389]

 Future<void> _share() async {
     final s = widget.snapshot;
     final d = _detail;
+    final units = context.read<UnitsController>();
+    final navigator = Navigator.of(context);
     final data = buildWorkoutShareData(
-      units: context.read<UnitsController>(),
-      ...
+      units: units,
+      type: s.type,
+      duration: s.duration,
+      when: DateTime.now(),
+      maxHr: _maxHr,
+      strain: (d?['strain'] as num?)?.toDouble() ?? s.strain,
+      calories: (d?['calories'] as num?)?.toInt() ?? s.calories.round(),
+      route: _route,
+      avgHr: (d?['avg_hr'] as num?)?.toInt(),
     );
     if (!mounted) return;
-    await Navigator.of(context).push(
+    await navigator.push(
       themedRoute((_) => WorkoutSharePreviewScreen(data: data),
           name: 'WorkoutSharePreviewScreen'),
     );
   }
Suggestion importance[1-10]: 6

__

Why: This is a real Flutter best-practice issue — context.read<UnitsController>() is called synchronously before any await, so it's safe as written, but Navigator.of(context) is used after the await navigator.push(...) call. Capturing Navigator.of(context) before the async gap is the correct pattern and the improved_code accurately reflects the fix.

Low
Context used after await without mounted guard

Navigator.of(context) is called after await without a mounted guard. If the widget
is disposed while the push is in flight (e.g. the user navigates away), this throws
a context.read / Navigator error — a recurring crash pattern in this codebase
(§4.5). Add a mounted check before the push.

lib/ui/workouts/workouts_screen.dart [933-940]

 Future<void> _share() async {
   final data = _shareData.value;
   if (data == null) return;
+  if (!mounted) return;
   await Navigator.of(context).push(
     themedRoute((_) => WorkoutSharePreviewScreen(data: data),
         name: 'WorkoutSharePreviewScreen'),
   );
 }
Suggestion importance[1-10]: 6

__

Why: The _share() method uses Navigator.of(context) after an await without a mounted check, which is a real potential crash if the widget is disposed mid-flight. Adding a mounted guard is a standard Flutter safety pattern and the fix is straightforward and correct.

Low
Absent timestamp fabricates current time instead of abstaining

When startTs is absent or zero, the code falls back to DateTime.now(), fabricating a
timestamp for the share card rather than abstaining. This violates the hard "never
fabricate a metric" invariant (§3.3): a share card with a wrong date is worse than
no share card. Set widget.shareData.value = null and return early instead of
substituting the current time.

lib/ui/workouts/workouts_screen.dart [1079-1081]

-when: startTs != null && startTs > 0
-    ? DateTime.fromMillisecondsSinceEpoch(startTs * 1000).toLocal()
-    : DateTime.now(),
+if (startTs == null || startTs <= 0) {
+  widget.shareData.value = null;
+  return;
+}
+widget.shareData.value = buildWorkoutShareData(
+  units: context.read<UnitsController>(),
+  type: (d['type'] as String?) ?? '',
+  duration: Duration(seconds: durationSec.round()),
+  when: DateTime.fromMillisecondsSinceEpoch(startTs * 1000).toLocal(),
+  maxHr: context.read<AppState>().maxHr,
+  strain: (d['strain'] as num?)?.toDouble() ?? 0,
+  calories: (d['calories'] as num?)?.toInt() ?? 0,
+  route: _route,
+  avgHr: (d['avg_hr'] as num?)?.toInt(),
+);
Suggestion importance[1-10]: 5

__

Why: The fallback to DateTime.now() when startTs is null or zero does fabricate a timestamp, which could mislead users. However, this is a defensive fallback for edge cases (corrupted/missing data), and the suggestion to return null instead is a reasonable design choice but not a critical bug — the workout data is still real, only the timestamp is approximate.

Low
Verify milestone dedup set is persistent, not recomputed

When fired is null (i.e. _app or activeWorkout is null), the milestone is silently
swallowed rather than deduped. This means a milestone that fires while the workout
object is momentarily unavailable is lost forever — it will never re-fire because
the key is never added to the set. The two conditions have different semantics and
should be separated: a null fired should be treated as "not ready, skip" (current
behavior is correct), but the logic is already correct as written. However, the real
risk is the inverse: if activeWorkout is non-null but firedMilestones is a freshly
constructed set on every access (a getter that returns {} each time), fired.add(key)
always returns true and the dedup never works. Verify that firedMilestones is a
persistent field on LiveWorkoutState, not a computed getter.

lib/ui/activity/live_session_screen.dart [274-276]

 void _milestone(String key, String big, String sub, Color c) {
     final fired = _app?.activeWorkout?.firedMilestones;
-    if (fired == null || !fired.add(key)) return;
+    if (fired == null) return;
+    if (!fired.add(key)) return;
Suggestion importance[1-10]: 4

__

Why: The suggestion raises a valid concern about whether firedMilestones is a persistent field or a recomputed getter, but this is a verification request about code outside the PR diff rather than a concrete bug in the shown code. The existing logic if (fired == null || !fired.add(key)) return; is correct as written.

Low
Screen wake lock leaks on route-start failure paths

ScreenWake.enable() is called inside _maybeStartRouteTracking but there is no
corresponding ScreenWake.release() on the early-return failure paths in that same
method (permission denied, _disposed, or activeWorkout?.workoutId != id mismatch).
If the method returns early after ScreenWake.enable() has already been called, the
display stays pinned awake for the rest of the process. The release must be
unconditional on every exit path, mirroring the pattern already applied to
_finishWorkout.

lib/state/app_state.dart [3307-3310]

-ScreenWake.enable();
-notifyListeners();
-_log('Route tracking started for $type.');
+try {
+  ScreenWake.enable();
+  notifyListeners();
+  _log('Route tracking started for $type.');
+} catch (_) {
+  ScreenWake.release();
+  rethrow;
+}
Suggestion importance[1-10]: 3

__

Why: Looking at the code, ScreenWake.enable() is called at line 3308, AFTER the e...

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 12

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/absent_not_zero_test.dart (1)

441-453: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

This test asserts its own inputs, not MiniBars' behaviour.

t.widget<MiniBars>(...).values just reads back the list passed to the constructor, so the assertions hold no matter how MiniBars renders — the stated invariant (a null day keeps its slot rather than shifting the week left) is no longer guarded. This file already has _paintedBars(t); counting painted bars actually exercises it.

💚 Assert the rendered output instead
       await t.pump(const Duration(milliseconds: 700));
-      final bars = t.widget<MiniBars>(find.byType(MiniBars));
-      expect(bars.values.length, 7); // was 6 — Thu–Sun slid onto Wed–Sat
-      expect(bars.values[2], isNull);
+      // 7 slots, 6 drawn bars: the gap is skipped, not compacted away.
+      expect(_paintedBars(t), 6);
       expect(t.takeException(), isNull);

As per coding guidelines, "Behavior changes, especially regressions involving readiness, abstention, idempotence, synchronization, migrations, and lifecycle safety, must include regression tests."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/absent_not_zero_test.dart` around lines 441 - 453, Update the “keeps a
missing day in place” test to assert rendered output rather than reading the
constructor input through bars.values. Use the existing _paintedBars(t) helper
to verify the rendered bar count and preserve the null-day slot invariant, while
retaining the exception check.

Source: Coding guidelines

🧹 Nitpick comments (3)
test/design_redesign_test.dart (1)

63-90: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider also tapping the chip to prove it doesn't swallow the ring's onTap.

In OrbitScore the chip sits inside the Pressable that carries onTap, and StateChipView wraps itself in its own Pressable (with a null callback). The current test only taps the score text, so the chip-area tap path is unverified.

💚 Extra assertion
       await t.tap(find.text('82'));
       await t.pump(const Duration(milliseconds: 250));
       expect(core, 1);
+      // A tap on the status chip must still reach the ring — the chip's own
+      // Pressable (no onTap) must not absorb it.
+      await t.tap(find.text('Push'));
+      await t.pump(const Duration(milliseconds: 250));
+      expect(core, 2);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/design_redesign_test.dart` around lines 63 - 90, Extend the existing
OrbitScore widget test around the StateChipView assertion to tap the rendered
“Push” chip and verify the parent onTap still increments core. Keep the existing
score-text tap assertion, and ensure the chip tap confirms StateChipView does
not swallow OrbitScore’s Pressable callback.
lib/ui/workouts/workouts_screen.dart (1)

1058-1088: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Share data is formatted once at load, so a units change afterwards is not reflected.

_publishShareData snapshots UnitsController output; switching metric/imperial while this screen is open leaves the share card on the old unit. Rebuilding the notifier value in didChangeDependencies (or storing raw values and formatting in _share) would keep it honest.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/ui/workouts/workouts_screen.dart` around lines 1058 - 1088, Update the
workout share-data flow around _publishShareData so it is rebuilt when
dependencies change, including after a UnitsController metric/imperial switch.
Invoke _publishShareData from didChangeDependencies (while preserving the
existing null, live-workout, and formatting behavior) so widget.shareData
reflects the current units.
lib/ui/activity/live_session_screen.dart (1)

1731-1742: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Stale comment block: it describes a banner that no longer exists.

The "Recording state" / "mutually exclusive with the stall banner" paragraph now sits directly above the re-centre button, with no recording banner in between — it reads as documentation for code that was deleted. Same pattern at Lines 1353-1369, where _share's doc paragraph is duplicated almost verbatim.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/ui/activity/live_session_screen.dart` around lines 1731 - 1742, Remove
the stale “Recording state” and “mutually exclusive with the stall banner”
comments above the re-centre button, since no recording banner remains there.
Also remove the duplicated obsolete _share documentation block around the
corresponding earlier section, preserving only comments that describe currently
existing code.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/privacy.html`:
- Around line 87-91: Update the privacy statement in the route-related list item
to clarify that the app does not upload routes itself, while explicitly noting
that route imagery or data may leave the device when the user chooses to share a
workout. Preserve the existing claims about diagnostics and AI Coach access
where applicable.

In `@lib/compute/derive_scheduler.dart`:
- Around line 165-168: Update _drain() to re-check the workout/activity gate
immediately after await LocalDb.takeNextComputeJob() returns, before running
derivation. If a workout starts during acquisition, atomically requeue or retain
the claimed job and exit without processing it; alternatively serialize this
admission with workout-state changes, preserving existing behavior when no
workout is active.

In `@lib/gps/screen_wake.dart`:
- Around line 44-56: Update _set so _on is changed only after the platform
invokeMethod call completes successfully and returns true; do not latch the
requested value before the call. Treat Android’s false result, exceptions, and
unsuccessful release identically by leaving _on unchanged so later requests can
retry, while preserving the existing platform selection and error logging.

In `@lib/state/app_state.dart`:
- Around line 3307-3308: Move or add ScreenWake.enable() to the startWorkout()
flow and the orphan-session rehydration path so every live-workout entry,
including other, denied-location, and resumed non-route sessions, enables the
screen wake capability. Keep the existing unconditional ScreenWake.release()
teardown behavior unchanged.
- Line 754: Override notifyListeners() in the app state class to return without
notifying when _disposed is true, and otherwise delegate to the superclass
implementation. Keep setting _disposed in dispose() before super.dispose() so
scheduler callbacks and in-flight _afterDrain() continuations are covered by the
centralized guard.

In `@lib/ui/activity/live_session_screen.dart`:
- Around line 415-419: Update the almostText expression in the live session
screen to use Dart string interpolation for gapBpm and _zones[zone + 1].label
instead of escaping the dollar signs, so athletes see the calculated BPM gap and
zone label rather than the raw template.

In `@lib/ui/activity/workout_share_card.dart`:
- Around line 376-393: Update the share flow around the temporary PNG creation
to reuse a stable filename or remove prior openstrap_*.png files before writing,
and delete the generated file after sharing completes when safe. In the catch
block, log the exception through the existing logging mechanism and replace the
interpolated “Couldn’t share: $e” snackbar text with a fixed user-facing
message.
- Around line 547-562: Update the route handling around hasRoute so Dart
promotes the nullable value before use: bind route to a local non-null variable
and guard it with a direct null check plus hasPath, then use that promoted
variable for distanceMeters and points within the block.

In `@lib/ui/kit/route_map.dart`:
- Around line 185-194: Update _rebuildIfNeeded so its memoization key also
tracks the active palette and widget.interactive, rebuilding _points, _glow, and
_crisp whenever either changes even when widget.vertices is identical. Preserve
the existing identity-based vertex optimization and update all cached key state
after a rebuild.

In `@test/workout_reliability_test.dart`:
- Around line 81-89: Strengthen the live-workout test by enqueueing a light or
heavy derivation job before waiting past the settle interval, then assert it
remains unrun while the workout is active. Release the workout hold afterward
and assert the queued job runs exactly once, using the existing scheduler/job
APIs and the test’s runs counter.
- Around line 92-150: Update the ScreenWake test setup and implementation seam
so enable/release dispatch can be exercised on host tests without relying on
Platform.isAndroid or Platform.isIOS; ensure the repeated-enable call-count and
channel-failure tests invoke the mocked MethodChannels, while preserving
production platform gating.

In `@test/workout_share_card_test.dart`:
- Around line 21-27: Update the zone argument in the _route() helper to convert
the result of (i ~/ 8).clamp(0, 5) to an int before passing it to RouteVertex,
while preserving the existing clamping range and values.

---

Outside diff comments:
In `@test/absent_not_zero_test.dart`:
- Around line 441-453: Update the “keeps a missing day in place” test to assert
rendered output rather than reading the constructor input through bars.values.
Use the existing _paintedBars(t) helper to verify the rendered bar count and
preserve the null-day slot invariant, while retaining the exception check.

---

Nitpick comments:
In `@lib/ui/activity/live_session_screen.dart`:
- Around line 1731-1742: Remove the stale “Recording state” and “mutually
exclusive with the stall banner” comments above the re-centre button, since no
recording banner remains there. Also remove the duplicated obsolete _share
documentation block around the corresponding earlier section, preserving only
comments that describe currently existing code.

In `@lib/ui/workouts/workouts_screen.dart`:
- Around line 1058-1088: Update the workout share-data flow around
_publishShareData so it is rebuilt when dependencies change, including after a
UnitsController metric/imperial switch. Invoke _publishShareData from
didChangeDependencies (while preserving the existing null, live-workout, and
formatting behavior) so widget.shareData reflects the current units.

In `@test/design_redesign_test.dart`:
- Around line 63-90: Extend the existing OrbitScore widget test around the
StateChipView assertion to tap the rendered “Push” chip and verify the parent
onTap still increments core. Keep the existing score-text tap assertion, and
ensure the chip tap confirms StateChipView does not swallow OrbitScore’s
Pressable callback.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c45b3aff-a793-4e6a-8728-5e4590fb3a1b

📥 Commits

Reviewing files that changed from the base of the PR and between d4641ca and 062884a.

📒 Files selected for processing (41)
  • PRIVACY.md
  • android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/EdgeTrackingService.kt
  • android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt
  • docs/privacy.html
  • ios/Runner/AppDelegate.swift
  • ios/Runner/Info.plist
  • lib/ai/briefing_engine.dart
  • lib/compute/derive_scheduler.dart
  • lib/gps/gps_source.dart
  • lib/gps/screen_wake.dart
  • lib/main.dart
  • lib/state/app_state.dart
  • lib/theme/tokens.dart
  • lib/ui/activity/live_session_screen.dart
  • lib/ui/activity/workout_share_card.dart
  • lib/ui/design/design.dart
  • lib/ui/design/gallery_screen.dart
  • lib/ui/design/nav_pill.dart
  • lib/ui/design/orbit_score.dart
  • lib/ui/design/radial_heatmap.dart
  • lib/ui/design/recap_card.dart
  • lib/ui/design/state_chips.dart
  • lib/ui/kit/charts.dart
  • lib/ui/kit/kit.dart
  • lib/ui/kit/os_icons.dart
  • lib/ui/kit/route_map.dart
  • lib/ui/timeline/timeline_screen.dart
  • lib/ui/today/today_screen.dart
  • lib/ui/workouts/workouts_screen.dart
  • test/absent_not_zero_test.dart
  • test/ai_briefing_test.dart
  • test/core_screens_test.dart
  • test/design_redesign_test.dart
  • test/design_system_test.dart
  • test/live_session_layout_test.dart
  • test/no_debug_only_apis_test.dart
  • test/ui_kit_new_widgets_test.dart
  • test/workout_reliability_test.dart
  • test/workout_share_card_test.dart
  • test/workout_sleep_redesign_test.dart
  • test/zone_contrast_test.dart
💤 Files with no reviewable changes (5)
  • lib/ui/kit/kit.dart
  • lib/ui/design/radial_heatmap.dart
  • lib/ui/design/nav_pill.dart
  • lib/ui/design/design.dart
  • test/ui_kit_new_widgets_test.dart

Comment thread docs/privacy.html Outdated
Comment thread lib/compute/derive_scheduler.dart
Comment thread lib/gps/screen_wake.dart Outdated
Comment thread lib/state/app_state.dart
Comment thread lib/state/app_state.dart Outdated
Comment on lines +547 to +562
final hasRoute = route != null && route.hasPath;
final title = type.isEmpty
? 'Workout'
: type[0].toUpperCase() + type.substring(1);

String heroValue;
String heroUnit;
List<(String, String)> stats;
if (hasRoute) {
final parts = units.distance(route.distanceMeters).split(' ');
heroValue = parts.first;
heroUnit = parts.length > 1 ? parts.sublist(1).join(' ') : '';
stats = [
(_shareDuration(duration), 'Time'),
// Moving pace, like everywhere else — see the note in _GpsControlPanel.
(units.pace(route.distanceMeters, route.movingSec), 'Pace'),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Dart type promotion via local boolean variable nullable parameter supported version

💡 Result:

Dart's flow-based type promotion supports local variables (including nullable parameters) when they are checked against null or a specific type within a function [1][2][3]. Regarding your query about boolean variables and type promotion: Dart does not perform "type predicates" like TypeScript, where a boolean function return value automatically triggers type promotion in the calling scope [4]. However, Dart's flow analysis does track promotions associated with boolean expressions within the same function body [4]. Key details include: 1. Scope Limitation: Type promotion occurs based on control flow analysis and is strictly limited to the current function scope [4]. Dart cannot promote a variable based on the result of a separate function call or an external boolean variable (i.e., promotion does not "escape" the scope where the check occurred) [4]. 2. Supported Targets: As of Dart 3.2, type promotion applies to local variables, parameters, and private final fields [1][5]. Previously, only local variables were promotable [1][5]. 3. Mechanism: If you need to promote a nullable parameter (e.g., String? x) or a property, the recommended approach is to assign the value to a local final variable at the beginning of the scope (e.g., final localX = x;) and perform your checks on that local variable [6][5]. Because local variables cannot be modified by external code or overridden, Dart's flow analysis can soundly promote them [7][5]. In summary, while you cannot use a custom boolean flag to "activate" promotion across different scopes, you can use local variables and standard null checks (if (x != null)) to achieve type promotion effectively within a single function [6][1][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the file around the referenced lines.
FILE="lib/ui/activity/workout_share_card.dart"
wc -l "$FILE"
sed -n '500,620p' "$FILE"

# Find the declaration of `route` and `hasRoute` in this file.
rg -n "\broute\b|\bhasRoute\b" "$FILE"

Repository: OpenStrap/edge

Length of output: 4641


🌐 Web query:

Dart promotion through final bool local variable if (hasRoute) route.distanceMeters

💡 Result:

In Dart, you cannot directly use a local boolean variable to "promote" a separate variable (such as route) to a non-nullable type within an if block [1][2]. Even if the boolean variable (e.g., final bool hasRoute = route != null;) is declared as final, Dart's flow analysis engine does not automatically link the state of that boolean to the nullability of the original variable route [1][2]. The compiler treats these as independent entities; it does not track that the truth value of the boolean implies the nullability status of the other variable [2]. To achieve type promotion, you must perform the null check directly within the if statement itself [3][2]. Recommended patterns: 1. Direct check (Preferred): Perform the null check directly in the condition. This allows Dart to soundly promote the variable for the scope of the block [3][4]. dart if (route != null) { // route is promoted to non-nullable here print(route.distanceMeters); } 2. Local variable assignment (Alternative): If you must use a local variable, assign the nullable object to a new, non-nullable local variable inside the block. This is a common and safe pattern [3][4]. dart final localRoute = route; if (localRoute != null) { // Use localRoute instead of the original route print(localRoute.distanceMeters); } While there have been discussions and proposals regarding more advanced flow analysis features, the current language design requires the check to be tied directly to the variable being promoted [5][2]. Using an intermediate boolean variable will generally result in a compilation error (e.g., "The property can't be accessed because the receiver can be null") because the compiler cannot guarantee the relationship between the two variables [1][2].

Citations:


Use a direct null check here. hasRoute does not promote route, so route.distanceMeters and route.points are still nullable inside this block. Bind final r = route; and check if (r != null && r.hasPath) (or check route != null directly) before using it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/ui/activity/workout_share_card.dart` around lines 547 - 562, Update the
route handling around hasRoute so Dart promotes the nullable value before use:
bind route to a local non-null variable and guard it with a direct null check
plus hasPath, then use that promoted variable for distanceMeters and points
within the block.

Comment thread lib/ui/kit/route_map.dart
Comment thread test/workout_reliability_test.dart
Comment thread test/workout_reliability_test.dart
Comment on lines +21 to +27
List<RouteVertex> _route() => [
for (var i = 0; i < 40; i++)
RouteVertex(
LatLng(51.5074 + i * 0.0004, -0.1278 + i * 0.0003),
(i ~/ 8).clamp(0, 5),
),
];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/sh
rg -n -A20 -B2 'class RouteVertex|const RouteVertex|RouteVertex\(' lib/gps/route_models.dart
rg -n '\.clamp\(' test/workout_share_card_test.dart

Repository: OpenStrap/edge

Length of output: 1480


Convert the clamped zone to int. int.clamp() returns num, so this won’t type-check against RouteVertex’s int? zone.

Proposed fix
-          (i ~/ 8).clamp(0, 5),
+          ((i ~/ 8).clamp(0, 5)).toInt(),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
List<RouteVertex> _route() => [
for (var i = 0; i < 40; i++)
RouteVertex(
LatLng(51.5074 + i * 0.0004, -0.1278 + i * 0.0003),
(i ~/ 8).clamp(0, 5),
),
];
List<RouteVertex> _route() => [
for (var i = 0; i < 40; i++)
RouteVertex(
LatLng(51.5074 + i * 0.0004, -0.1278 + i * 0.0003),
((i ~/ 8).clamp(0, 5)).toInt(),
),
];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/workout_share_card_test.dart` around lines 21 - 27, Update the zone
argument in the _route() helper to convert the result of (i ~/ 8).clamp(0, 5) to
an int before passing it to RouteVertex, while preserving the existing clamping
range and values.

CodeRabbit found 12 things on #162. Ten were valid; two were wrong and are
left alone with the reasoning recorded below.

CRITICAL, and mine: the almost-there nudge shipped as
'\$gapBpm bpm to \${...} — push' — escaped, so the athlete saw the raw
template instead of "4 bpm to Z4 — push". A shell/python escape leaked
into Dart source. No test covered it because the nudge only appears
within 5 bpm of the next zone; there is one now.

Two of my tests asserted nothing, which is the same failure I caught in
my own layout test earlier and did not generalise from:
  - the derive-gate test never enqueued a job, so `runs == 0` passed with
    the gate deleted. It now queues real work, asserts it stays parked,
    and asserts it drains on release. Verified by deleting the gate.
  - the ScreenWake tests mocked two MethodChannels that were never
    reached: Platform.isAndroid and isIOS are BOTH false on the host VM,
    so the dispatch short-circuited. Added a test seam.

Correctness:
  - _drain() cleared the gate, then awaited takeNextComputeJob(); a
    workout starting inside that window still got a derive pass, and the
    job was already marked running. Re-checks after acquisition and hands
    the job back via a new LocalDb.requeueComputeJob (which also undoes
    the attempt increment — being deferred is not a failure).
  - ScreenWake latched _on before the platform answered. Android returns
    false when no activity is attached, so a failed enable left Dart
    believing the screen was held and suppressed every later retry.
  - _disposed only guarded the paths someone remembered. notifyListeners()
    is now overridden to no-op when disposed, which covers the scheduler's
    onChanged callback and in-flight _afterDrain() continuations too.
  - ScreenWake was armed inside _maybeStartRouteTracking, so indoor
    workouts, location-denied runs and resumed non-route sessions never
    held the screen. Moved to startWorkout() plus orphan rehydration.
  - RouteMapView's memo key was the vertex list alone, but the cached
    polylines bake in the ACTIVE palette (via _colorFor) and `interactive`
    (stroke width). A theme switch left a finished route in the old
    palette's colours — and the design gallery toggles the theme with
    this widget on screen. Key now includes both, and is re-checked in
    build() because the palette is a global static that need not trigger
    didUpdateWidget.

Privacy: "Routes never leave your device" was too absolute now that Share
renders a route into an image the user can send anywhere. Split into what
the App does (never sends them) and the one exception (you, deliberately,
having seen the image first). Both documents.

Also: shared PNGs used a timestamped filename and accumulated in temp
until the OS reclaimed them — one reused name now. And "Couldn't share:
$e" put an internal exception string in front of the athlete; logged
instead, with a fixed sentence shown.

REJECTED, with reasons:
  - "hasRoute does not promote route, so route.distanceMeters is still
    nullable": Dart 3.11 DOES promote through a final boolean local.
    Verified with a standalone probe; the analyzer is clean.
  - "int.clamp() returns num, so this won't type-check against int?":
    int.clamp(int, int) is statically int in Dart. Also verified with a
    probe that analyzes clean.

Unrelated flake found while verifying: notification_day_guard_test seeded
empty prefs for the cases that expect a present to SUCCEED, inheriting the
default 22:00-07:00 quiet window. Every one of those failed for nine hours
a night locally and passed on CI only because CI runs at a different hour.
Pinned quiet hours off, mirroring the _quietAllDay helper already there.

959 tests, analyzer clean.
@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

Review triage — 10 fixed, 2 disproved

Thanks, these were good. Verified every finding against the code rather than taking them on trust; two didn't survive that.

The important one

live_session_screen.dart:419 was real and mine. '\$gapBpm bpm to \${...}' was escaped, so the athlete saw the raw template instead of 4 bpm to Z4 — push. A shell escape leaked into Dart source while I was editing via a heredoc. No test covered it because the nudge only appears within 5 bpm of the next zone — there is one now.

Two of my tests asserted nothing

Both correctly called out:

  • the derive-gate test never enqueued a job, so runs == 0 passed with the gate deleted. It now queues real work, asserts it stays parked, and asserts it drains on release — verified by deleting the gate and watching it fail.
  • the ScreenWake tests mocked two MethodChannels that were never reached: Platform.isAndroid and isIOS are both false on the host VM, so the dispatch short-circuited. Added a test seam; they now exercise the channel, including the refusal-does-not-latch case.

Correctness

finding fix
_drain() race after takeNextComputeJob() re-checks the gates post-acquisition and hands the job back via a new requeueComputeJob (which also undoes the attempt increment — deferral isn't failure)
ScreenWake latched before the platform answered only latches on a confirmed true, so an Android refusal can retry
_disposed didn't cover every path notifyListeners() overridden — total instead of a list of remembered sites
wake only armed for route workouts moved to startWorkout() + orphan rehydration
RouteMapView memo key missed palette/interactive both in the key, and re-checked in build() since the palette is a global static that needn't trigger didUpdateWidget

Privacy

Right that "Routes never leave your device" was too absolute now that Share renders a route into a sendable image. Split into what the App does (never sends them) and the one exception — you, deliberately, having seen the image first. Both documents.

Also fixed: shared PNGs used a timestamped filename and accumulated in temp; and "Couldn't share: $e" put an internal exception in front of the athlete.

Not applied

Both disproved with standalone probes that analyze clean:

  • hasRoute doesn't promote route — Dart 3.11 does promote through a final boolean local. route.distanceMeters inside if (hasRoute) is type-safe.
  • int.clamp() returns numint.clamp(int, int) is statically int in Dart, so it type-checks against int? fine.

Unrelated flake, found while verifying

notification_day_guard_test seeded empty prefs for cases expecting a present to succeed, inheriting the default 22:00–07:00 quiet window. Those failed for nine hours a night locally and passed on CI only because CI runs at a different hour. Pinned quiet hours off, mirroring the _quietAllDay helper already in the file.

959 tests, analyzer clean.

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit ea5bae6

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/state/app_state.dart`:
- Around line 3227-3231: Serialize all ScreenWake transitions through one shared
operation so enable/release calls cannot overtake each other: update the start
and rehydration paths in lib/state/app_state.dart at lines 3227-3231 and 3436,
and serialize stop and cancellation teardown at lines 3465-3470 and 3534-3535,
ensuring teardown waits for any pending enable. In
test/workout_reliability_test.dart lines 123-191, add concurrent enable/release
coverage and verify ordered true-then-false channel calls with a final unheld
state.

In `@test/workout_reliability_test.dart`:
- Around line 81-97: The test around the queued-job session flow must exercise
the post-claim requeue path rather than only enqueueing while the gate is
closed. Arrange for the job to be acquired, then activate the workout before
execution completes; assert it is returned to queued with its attempt count
unchanged, and verify it executes exactly once after the workout is released.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 19c9004e-c898-4537-9204-7029563d1538

📥 Commits

Reviewing files that changed from the base of the PR and between 062884a and ea5bae6.

📒 Files selected for processing (12)
  • PRIVACY.md
  • docs/privacy.html
  • lib/compute/derive_scheduler.dart
  • lib/data/db.dart
  • lib/gps/screen_wake.dart
  • lib/state/app_state.dart
  • lib/ui/activity/live_session_screen.dart
  • lib/ui/activity/workout_share_card.dart
  • lib/ui/kit/route_map.dart
  • test/live_session_layout_test.dart
  • test/notification_day_guard_test.dart
  • test/workout_reliability_test.dart
🚧 Files skipped from review as they are similar to previous changes (7)
  • docs/privacy.html
  • lib/gps/screen_wake.dart
  • test/live_session_layout_test.dart
  • lib/compute/derive_scheduler.dart
  • lib/ui/kit/route_map.dart
  • lib/ui/activity/workout_share_card.dart
  • lib/ui/activity/live_session_screen.dart

Comment thread lib/state/app_state.dart
Comment on lines +3227 to +3231
// Hold the display for EVERY live session, not just route-eligible ones.
// Arming this from _maybeStartRouteTracking meant an indoor workout, a
// location-denied run, and a resumed non-route session all watched the
// screen sleep mid-set. Released unconditionally on both teardown paths.
ScreenWake.enable();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Serialize ScreenWake transitions.

enable() and release() inspect _on before their platform await. Thus a stop can skip release() while enable is in flight, after which enable latches on; conversely, a rapid new start can skip enable while release is in flight.

  • lib/state/app_state.dart#L3227-L3231: enqueue the start transition through a serialized ScreenWake operation.
  • lib/state/app_state.dart#L3436-L3436: use the same serialized transition on rehydration.
  • lib/state/app_state.dart#L3465-L3470: serialize stop after any pending enable.
  • lib/state/app_state.dart#L3534-L3535: serialize cancellation teardown after any pending enable.
  • test/workout_reliability_test.dart#L123-L191: add Future.wait([ScreenWake.enable(), ScreenWake.release()]) coverage and assert the final state is unheld with ordered true, then false channel calls.
Proposed ScreenWake fix
+  static Future<void> _transition = Future.value();
+
   static Future<void> _set(bool on) async {
+    _transition = _transition.then((_) => _setOnce(on));
+    return _transition;
+  }
+
+  static Future<void> _setOnce(bool on) async {
     if (on == _on) return;
     try {
       ...

As per coding guidelines, lib/**/*.dart: “When adding or changing a capability, cover every call path”; and test/**/*.dart: “Behavior changes … and lifecycle safety, must include regression tests.”

📍 Affects 2 files
  • lib/state/app_state.dart#L3227-L3231 (this comment)
  • lib/state/app_state.dart#L3436-L3436
  • lib/state/app_state.dart#L3465-L3470
  • lib/state/app_state.dart#L3534-L3535
  • test/workout_reliability_test.dart#L123-L191
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/state/app_state.dart` around lines 3227 - 3231, Serialize all ScreenWake
transitions through one shared operation so enable/release calls cannot overtake
each other: update the start and rehydration paths in lib/state/app_state.dart
at lines 3227-3231 and 3436, and serialize stop and cancellation teardown at
lines 3465-3470 and 3534-3535, ensuring teardown waits for any pending enable.
In test/workout_reliability_test.dart lines 123-191, add concurrent
enable/release coverage and verify ordered true-then-false channel calls with a
final unheld state.

Source: Coding guidelines

Comment thread test/workout_reliability_test.dart
CI caught a flake I introduced one commit ago: "a queued job stays parked
for the session, then runs on release" failed with Expected: <1>,
Actual: <0>. The drain does several DB round-trips and a fixed 120 ms
sleep is simply not enough headroom on a loaded runner — it passed on
this machine every time and failed on GitHub's.

Polls for the positive direction with a 5 s ceiling, returning as soon as
the condition holds, so the generous timeout costs nothing locally. The
negative direction keeps a fixed wait, because you cannot poll for
"this never happens"; 150 ms is comfortably past the 10 ms settle, and a
longer wait there only makes that assertion stronger.

Re-verified the test still bites by deleting the gate. Worth noting the
first attempt at that check was itself wrong: sed only removed the
_arm/_drain entry condition and the test still passed, because the
post-acquisition re-check added for the CodeRabbit race is a genuine
SECOND gate. Removing both does fail the test.
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 1e8cac2

Second CodeRabbit pass, both findings valid.

The race: _on is only updated AFTER the platform await, so a release()
arriving while an enable() is still in flight read the stale `false`,
decided it had nothing to do, and returned — then the in-flight enable
latched _on = true and the display stayed held for the rest of the app's
life. Both call sites in AppState are fire-and-forget, so starting a
workout and immediately stopping it was enough to hit it. Transitions now
run through a chain, so each sees the state the previous one left.

Covered by a test that fails on the unserialized version (isHeld true
instead of false) and asserts both transitions reach the platform in
order.

Also adds the requeueComputeJob coverage that was missing: the gate test
closed the gate BEFORE enqueueing, so no job was ever claimed and the
requeue path never ran.

That test deliberately exercises the PRIMITIVE rather than simulating the
interleaving. Hitting the real window means racing a DB round-trip, which
is a coin flip dressed up as a test — this same file already shipped one
of those and it failed on CI. What is pinned instead is the guarantee the
drain path depends on: a claimed job is not handed out twice, comes back
claimable after a requeue, and a deferral does not burn an attempt.
Verified by removing the attempts decrement and watching it fail.

One wart found while writing it, left alone: takeNextComputeJob returns
the row as it was BEFORE its own update, so the `attempts` it reports is
the pre-increment value. Documented in the test rather than changed —
nothing depends on that field today and it is not this PR's business.

962 tests, analyzer clean.
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 78cd72a

@abdulsaheel
abdulsaheel merged commit b56bdec into main Jul 27, 2026
3 checks passed
@abdulsaheel
abdulsaheel deleted the feat/live-activity-and-share branch July 27, 2026 17:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant