diff --git a/lib/app.dart b/lib/app.dart index e621ecca..6a7d8d74 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -26,6 +26,7 @@ import 'ui/activity/live_session_screen.dart'; import 'ui/ai/ai_breakdown_screen.dart'; import 'ui/journal/journal_compose_screen.dart'; import 'ui/stress/calm_breathing_screen.dart'; +import 'telemetry/telemetry_service.dart'; class OpenStrapApp extends StatefulWidget { const OpenStrapApp({super.key}); @@ -93,6 +94,7 @@ class _OpenStrapAppState extends State with WidgetsBindingObserver themeMode: theme.materialThemeMode, builder: (context, child) => ThemeSwitchOverlay(key: themeSwitchKey, child: child!), + navigatorObservers: [TelemetryNavigatorObserver()], home: const _Gate(), ); } @@ -100,6 +102,10 @@ class _OpenStrapAppState extends State with WidgetsBindingObserver /// Onboarding gate: pairing → app. CLOUD EXCISED — the old backend / auth / /// profile gate states are gone; once a band is paired we go straight to the shell. +/// Telemetry-only: the last AppRoute we logged, so _Gate's build() (which also +/// re-runs on a plain theme flip, not just a route change) doesn't double-log. +AppRoute? _telemetryLastRoute; + class _Gate extends StatelessWidget { const _Gate(); @override @@ -109,6 +115,11 @@ class _Gate extends StatelessWidget { // here used to repaint the entire home stack every second, which starved the // background BLE connection on long idle stretches (lost overnight data). final route = context.select((a) => a.route); + if (route != _telemetryLastRoute) { + _telemetryLastRoute = route; + TelemetryService.instance.setContext('app_route', route.name); + TelemetryService.instance.breadcrumb('route: ${route.name}'); + } // Depend on the theme too → the whole home stack (onboarding screens, the // shell + its tabs) rebuilds with fresh colours the instant the mode flips. context.watch(); @@ -191,7 +202,8 @@ class _ShellState extends State<_Shell> { _ => null, }; if (screen == null) return; - Navigator.of(context).push(themedRoute((_) => screen)); + Navigator.of(context) + .push(themedRoute((_) => screen, name: screen.runtimeType.toString())); } // Built fresh on every build (not const) so a theme flip re-colours every tab, @@ -335,7 +347,8 @@ class _LiveBannerState extends State<_LiveBanner> with SingleTickerProviderState onTap: () { HapticFeedback.selectionClick(); Navigator.of(context).push(themedRoute( - (_) => LiveSessionScreen(workoutId: w.workoutId, type: w.type))); + (_) => LiveSessionScreen(workoutId: w.workoutId, type: w.type), + name: 'LiveSessionScreen')); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: Sp.x4, vertical: Sp.x3), diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 3212d6f9..511e337b 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -31,8 +31,10 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_performance/firebase_performance.dart'; import '../data/db.dart'; +import '../data/day_label.dart'; import '../notify/notification_center.dart'; import '../notify/notification_event.dart'; +import '../telemetry/telemetry_service.dart'; import 'crossday_pipeline.dart'; import 'derive_prepare.dart'; import 'onehz_pipeline.dart'; @@ -1198,6 +1200,10 @@ class DerivationEngine { /// skipped so the sweep always makes progress. static const Duration _perDayTimeout = Duration(seconds: 90); + /// Throttle for the readiness-absent diagnostic log — one per calendar day + /// so repeated light-pass re-derives of today don't spam the outbox. + String? _loggedReadinessAbsentFor; + /// Bounded worker-pool size for concurrent per-day derivation. Days within /// a single run share ONE frozen baseline snapshot (`_BaselineHistoryCache` /// is loaded once before the loop, refreshed once after — see `run()`) and @@ -1359,6 +1365,39 @@ class DerivationEngine { () => deriveDayBundle(withHistory), ).timeout(_perDayTimeout); _logSpo2Diagnostics(day, input, bundle); + // Readiness came back absent for TODAY specifically (not a historical + // backfill day, which would just be noise) — log why. This ran inside + // Isolate.run so it couldn't call Firebase itself; it just returned the + // per-input diagnostic (see onehz_pipeline.dart's readinessAbsentDiag). + // Throttled to once/day so repeated light-pass re-derives of today don't + // spam the outbox with the same finding. + final absentDiag = bundle['readiness_absent_diag']; + if (absentDiag != null && + day.date == todayLabel() && + _loggedReadinessAbsentFor != day.date) { + _loggedReadinessAbsentFor = day.date; + TelemetryService.instance.breadcrumb('readiness absent: $absentDiag'); + // Flattened, not the raw nested map: record()'s Analytics forwarding + // only keeps num/String values as-is and stringifies everything else, + // so passing {'hrv': {'value': ..., 'baseline_n': ...}, ...} directly + // would turn each input into one unqueryable "{value: true, ...}" + // string instead of separately filterable fields. + final diag = (absentDiag as Map).cast(); + final flat = {}; + for (final key in ['hrv', 'rhr', 'resp', 'temp']) { + final v = (diag[key] as Map?)?.cast(); + if (v == null) continue; + flat['${key}_value'] = v['value']; + flat['${key}_baseline_n'] = v['baseline_n']; + } + flat['note'] = diag['note']; + TelemetryService.instance.record( + kind: 'event', + level: 'warn', + message: 'readiness_absent', + context: flat, + ); + } // Where this day's sleep window came from (auto / auto_fallback / manual / // confirmed) — drives the Sleep screen's "is this right?" prompt + the @@ -1711,10 +1750,16 @@ class DerivationEngine { return; } final profileMap = profile.toMap(); - final bundle = await Isolate.run( - () => buildCrossDayBundle(days, profileMap), + // Encode INSIDE the isolate too — a real ~3.5-4.7s main-isolate hang was + // caught in production (Crashlytics jank_watchdog, correlated with a + // heavy derive pass) coming from jsonEncode-ing this bundle back on the + // main isolate after Isolate.run returned it. Returning the already- + // encoded string avoids both the main-isolate encode cost AND transfers + // a flat string across the isolate boundary instead of a large nested Map. + final bundleJson = await Isolate.run( + () => jsonEncode(buildCrossDayBundle(days, profileMap)), ).timeout(_crossDayTimeout); - await LocalDb.putBaseline('crossday', jsonEncode(bundle)); + await LocalDb.putBaseline('crossday', bundleJson); _log('crossday: stored over ${days.length} day(s)'); } catch (e) { _log('crossday FAILED/skipped: $e'); @@ -1744,19 +1789,26 @@ class DerivationEngine { } Future>> _refreshCrossDayInputArtifact() async { + // The DB read itself must stay on the main isolate (sqflite), but + // decoding up to _crossDayWindow (90) full day payloads + re-encoding + // them was previously ALL synchronous main-isolate work with zero + // offloading — this is the confirmed source of the ~3.5-4.7s production + // hang (Crashlytics jank_watchdog), since _refreshBaselines calls this + // unconditionally on every heavy pass. _decodeBundle/_crossDayRecord are + // both static, so this whole transform+encode step is isolate-safe. final rows = await LocalDb.recentDayResults(_crossDayWindow); - final days = >[]; - for (final row in rows.reversed) { - final payload = _decodeBundle(row['payload_json']); - if (payload == null) continue; - if (payload['skipped'] == true) continue; - final rec = _crossDayRecord(row, payload); - if (rec != null) days.add(rec); - } - await LocalDb.putBaseline( - 'crossday_input', - jsonEncode({'algo_version': kAlgoVersion, 'days': days}), - ); + final (days, json) = await Isolate.run(() { + final days = >[]; + for (final row in rows.reversed) { + final payload = _decodeBundle(row['payload_json']); + if (payload == null) continue; + if (payload['skipped'] == true) continue; + final rec = _crossDayRecord(row, payload); + if (rec != null) days.add(rec); + } + return (days, jsonEncode({'algo_version': kAlgoVersion, 'days': days})); + }); + await LocalDb.putBaseline('crossday_input', json); return days; } diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index 29abf42e..cdffee9e 100644 --- a/lib/compute/onehz_pipeline.dart +++ b/lib/compute/onehz_pipeline.dart @@ -360,6 +360,23 @@ Map deriveDayBundle(Map inputJson) { // pass raw values + their raw baselines). tempInput(skinTempAdc, d.skinTempAdcHistory), ]); + // Diagnostic only — populated when readiness comes back absent, so the main + // isolate can log WHY to Crashlytics instead of a bare null (this runs + // inside Isolate.run, so it can't call Firebase directly; it just returns + // data). Per-input value-presence + baseline length lets us distinguish + // "no value" / "baseline too short" from "everything present but MAD was + // degenerate" by elimination (readinessComposite doesn't surface the last + // case in its own note — see readiness_composite.dart's robustZ() null path). + Map? readinessAbsentDiag; + if (!composite.present) { + readinessAbsentDiag = { + 'hrv': {'value': lnToday != null, 'baseline_n': d.lnRmssdHistory.length}, + 'rhr': {'value': rhrToday != null, 'baseline_n': d.rhrHistory.length}, + 'resp': {'value': respToday != null, 'baseline_n': d.respHistory.length}, + 'temp': {'value': skinTempAdc != null, 'baseline_n': d.skinTempAdcHistory.length}, + 'note': composite.note, + }; + } // Plews lnRMSSD readiness over the trailing history INCLUDING today. final lnHist = [...d.lnRmssdHistory, ?lnToday]; final lnReadiness = lnHist.length >= 4 @@ -754,6 +771,7 @@ Map deriveDayBundle(Map inputJson) { 'clean_fraction': _round(corrected.cleanFraction, 4), 'sleep_seconds': inBedSec ?? 0, }, + 'readiness_absent_diag': ?readinessAbsentDiag, 'scalars': { 'rhr': rhrScalar, // Headline RMSSD (robust nocturnal, NREM). Whole-window kept separately. diff --git a/lib/main.dart b/lib/main.dart index 28d7bfb5..b429b31c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -38,6 +38,10 @@ Future main() async { // catch framework + uncaught async errors on their own, and a custom root zone // is a known source of release-startup fragility. TelemetryService.instance.installErrorHandlers(); + // Turns real frame-level jank into Crashlytics non-fatal reports — Crashlytics + // otherwise has zero visibility into "the app froze while scrolling" since + // freezing isn't a crash. See installJankWatchdog's doc for the threshold. + TelemetryService.instance.installJankWatchdog(); // Android: Cancel the two legacy WorkManager tasks by unique name. A previous // version scheduled heavy derivation passes in the background, but they were diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 44e2522a..14873c4e 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -700,10 +700,18 @@ class AppState extends ChangeNotifier { /// sweep. Best-effort + non-blocking — never throws into the BLE path. /// Refreshes the UI when results land so screens re-read the fresh derived rows. Future _afterDrain({bool heavy = false}) async { + final mode = heavy ? 'heavy' : 'light'; try { + // Context for whatever crash/ANR report comes next — the derivation + // engine's heavy per-day compute is isolate-offloaded, but the + // assembly/UI-refresh wiring around it still runs on the main isolate, + // so this is real signal if a freeze/ANR correlates with a derive pass. + TelemetryService.instance.setContext('derive_mode', mode); + TelemetryService.instance.setContext('derive_active', true); + TelemetryService.instance.breadcrumb('derive: $mode start'); // Refresh the UI after EACH day so Today/trends fill in as the sweep runs, // not only at the end (a multi-day backfill can be many days of work). - await _derive.run( + await TelemetryService.instance.traced('derive_$mode', () => _derive.run( _profile, heavy: heavy, onDayDone: (day, index, total) async { @@ -712,7 +720,8 @@ class AppState extends ChangeNotifier { notifyListeners(); } }, - ); + )); + TelemetryService.instance.breadcrumb('derive: $mode done'); await LocalDb.refreshComputeFreshness(); _bumpInsightsRevision(); notifyListeners(); // screens re-fetch from the derived store @@ -756,8 +765,14 @@ class AppState extends ChangeNotifier { if (heavy && healthShareConsent) { unawaited(HealthUploader.instance.maybeUpload(consented: true)); } - } catch (e) { + } catch (e, st) { _log('[derive] post-drain failed: $e'); + // Was silently swallowed before — this is a real pipeline failure + // (derive/health-export/etc.) that Firebase never saw. Non-fatal, not + // fatal: the app keeps running, but this is worth knowing about. + TelemetryService.instance.recordNonFatal(e, st, reason: 'post_drain_failed'); + } finally { + TelemetryService.instance.setContext('derive_active', false); } } diff --git a/lib/telemetry/telemetry_service.dart b/lib/telemetry/telemetry_service.dart index 72e5db07..fc36cc0c 100644 --- a/lib/telemetry/telemetry_service.dart +++ b/lib/telemetry/telemetry_service.dart @@ -17,6 +17,8 @@ import 'dart:io'; import 'package:battery_plus/battery_plus.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:firebase_core/firebase_core.dart'; @@ -97,6 +99,127 @@ class TelemetryService { }; } + // ── observability surface: breadcrumbs, context, non-fatals, traces ──────── + // + // Crashlytics only ever sees FATAL errors on its own (installErrorHandlers + // above) — it has zero visibility into freezes/jank or into errors that get + // caught-and-swallowed today. These helpers are how we get real signal out + // of Firebase for exactly those blind spots: + // - breadcrumb()/setContext(): attached automatically to whatever crash OR + // ANR report comes next from this session — the log() calls and the + // currently-set custom keys both ride along, no extra wiring needed. + // - recordNonFatal(): promotes a caught-and-swallowed error to a real + // Crashlytics issue instead of vanishing into debugPrint. + // - traced(): a Firebase Performance custom trace around a span of code — + // the only way to get real-world timing for BLE drains/derivation + // passes/health export, since Performance doesn't auto-instrument + // arbitrary Flutter rebuild cost. + // All best-effort + silently no-op before Firebase is configured or before + // the user has opted in, same gate as the rest of this file. + + /// Attach a breadcrumb log line to whatever Crashlytics report (crash OR + /// ANR) comes next from this session. Cheap; call liberally at lifecycle + /// transitions (screen changes, BLE state changes, derivation passes). + void breadcrumb(String message) { + try { + if (Firebase.apps.isNotEmpty && _enabled) { + FirebaseCrashlytics.instance.log(message); + } + } catch (_) {} + } + + /// Set a persistent custom key visible on every subsequent Crashlytics + /// report until overwritten — e.g. current_screen, ble_state, derive_mode. + /// Unlike breadcrumb(), this is STATE (last-write-wins), not an event log. + void setContext(String key, Object value) { + try { + if (Firebase.apps.isNotEmpty && _enabled) { + FirebaseCrashlytics.instance.setCustomKey(key, value); + } + } catch (_) {} + } + + /// Report a caught error as a Crashlytics NON-FATAL issue — for the many + /// `catch (e) { debugPrint(...) }` sites where a real problem currently just + /// vanishes into a debug console nobody in production ever reads. + void recordNonFatal(Object error, StackTrace stack, {String? reason}) { + try { + if (Firebase.apps.isNotEmpty && _enabled) { + FirebaseCrashlytics.instance.recordError( + error, + stack, + fatal: false, + reason: reason, + ); + } + } catch (_) {} + } + + final Map _activeTraces = {}; + + /// Wrap [body] in a named Firebase Performance trace. Safe to nest under + /// different names; a given [name] running concurrently with itself is not + /// supported (the later start wins) — use distinct names per call site. + Future traced(String name, Future Function() body) async { + Trace? trace; + try { + if (Firebase.apps.isNotEmpty && _enabled) { + trace = FirebasePerformance.instance.newTrace(name); + await trace.start(); + _activeTraces[name] = trace; + } + } catch (_) { + trace = null; + } + try { + return await body(); + } finally { + try { + await trace?.stop(); + } catch (_) {} + _activeTraces.remove(name); + } + } + + Timer? _jankThrottle; + + /// Turn invisible UI jank into real Crashlytics non-fatal reports. Flutter + /// itself already measures every frame's build+raster cost — we just have + /// to listen. A frame at/above [thresholdMs] reads as a visible stutter to + /// the user; this is what actually answers "the app froze while scrolling" + /// reports, which Crashlytics otherwise never sees at all (freezing isn't a + /// crash). Throttled to at most one report per [minGapSeconds] so a rough + /// patch (e.g. a long scroll over a busy screen) doesn't spam the outbox — + /// still enough to catch the pattern without drowning it. + void installJankWatchdog({int thresholdMs = 700, int minGapSeconds = 30}) { + SchedulerBinding.instance.addTimingsCallback((List timings) { + if (_jankThrottle != null) return; + for (final t in timings) { + final totalMs = t.totalSpan.inMilliseconds; + if (totalMs < thresholdMs) continue; + _jankThrottle = Timer(Duration(seconds: minGapSeconds), () { + _jankThrottle = null; + }); + final buildMs = t.buildDuration.inMilliseconds; + final rasterMs = t.rasterDuration.inMilliseconds; + breadcrumb( + 'slow_frame total=${totalMs}ms build=${buildMs}ms raster=${rasterMs}ms', + ); + recordNonFatal( + Exception('Slow frame: ${totalMs}ms (build=$buildMs raster=$rasterMs)'), + StackTrace.current, + reason: 'jank_watchdog', + ); + record(kind: 'event', level: 'warn', message: 'slow_frame', context: { + 'total_ms': totalMs, + 'build_ms': buildMs, + 'raster_ms': rasterMs, + }); + break; // one report per callback batch is enough signal + } + }); + } + /// Record an uncaught zone error (called from runZonedGuarded in main). void recordZoneError(Object error, StackTrace stack) => record(kind: 'crash', level: 'error', message: '$error', stack: '$stack'); @@ -238,3 +361,34 @@ class TelemetryService { String _clip(String s, int n) => s.length <= n ? s : s.substring(0, n); } + +/// Wire into MaterialApp's `navigatorObservers` so every real Navigator.push/ +/// pop (drill-down screens, modals, settings) sets `current_screen` + +/// breadcrumbs it — free "what screen were they on" context on every future +/// crash/ANR report. Note this only sees Navigator-based transitions; the +/// app's top-level AppRoute switch (loading/pairing/profile/shell) and any +/// IndexedStack-based tab switching inside the shell are NOT Navigator pushes +/// and need their own hook (see _Gate in app.dart for the top-level one). +class TelemetryNavigatorObserver extends NavigatorObserver { + String? _nameOf(Route? route) => + route?.settings.name ?? route?.runtimeType.toString(); + + void _report(String event, Route? route) { + final name = _nameOf(route); + if (name == null) return; + TelemetryService.instance.setContext('current_screen', name); + TelemetryService.instance.breadcrumb('nav: $event $name'); + } + + @override + void didPush(Route route, Route? previousRoute) => + _report('push', route); + + @override + void didPop(Route route, Route? previousRoute) => + _report('pop', previousRoute); + + @override + void didReplace({Route? newRoute, Route? oldRoute}) => + _report('replace', newRoute); +} diff --git a/lib/theme/theme_switcher.dart b/lib/theme/theme_switcher.dart index 15f42585..c2499e37 100644 --- a/lib/theme/theme_switcher.dart +++ b/lib/theme/theme_switcher.dart @@ -29,11 +29,18 @@ import 'tokens.dart'; /// fade-through transition therefore lives in the theme's pageTransitionsTheme /// instead (see buildOpenStrapTheme + page_transitions.dart): Android-likes /// keep the fade-through, iOS/macOS get the Cupertino slide WITH swipe-back. +/// [name] shows up as `current_screen` on the next Crashlytics crash/ANR +/// report (see TelemetryNavigatorObserver) — without it, every push here +/// falls back to a generic `MaterialPageRoute<...>` label, which isn't useful +/// for figuring out which screen a report actually happened on. Pass the +/// destination screen's name at each call site. PageRoute themedRoute( WidgetBuilder builder, { bool fullscreenDialog = false, + String? name, }) => MaterialPageRoute( fullscreenDialog: fullscreenDialog, + settings: RouteSettings(name: name), builder: (ctx) => _ThemeReactive(builder: builder), ); diff --git a/lib/ui/activity/live_session_screen.dart b/lib/ui/activity/live_session_screen.dart index 46105a84..9022a1eb 100644 --- a/lib/ui/activity/live_session_screen.dart +++ b/lib/ui/activity/live_session_screen.dart @@ -266,7 +266,8 @@ class _LiveSessionScreenState extends State if (!mounted) return; if (id != null) { Navigator.of(context).pushReplacement( - themedRoute((_) => WorkoutFinishScreen(id: id, snapshot: snap)), + themedRoute((_) => WorkoutFinishScreen(id: id, snapshot: snap), + name: 'WorkoutFinishScreen'), ); } else { Navigator.of(context).pop(); @@ -725,7 +726,9 @@ class _ControlPanel extends StatelessWidget { // Real steps counted on the live 100 Hz stream, scoped to THIS // workout (resets at start, not at connection). _Stat(icon: OsIcon.activity, label: 'STEPS', - value: context.watch().workoutSteps.toString(), + // select, not watch — this was subscribing the whole row to + // every AppState notifyListeners(), not just workoutSteps. + value: context.select((a) => a.workoutSteps).toString(), unit: ''), ]), ]), @@ -1447,7 +1450,8 @@ class _WorkoutFinishScreenState extends State Expanded( child: FilledButton( onPressed: () => Navigator.of(context).pushReplacement( - themedRoute((_) => WorkoutDetailScreen(id: widget.id)), + themedRoute((_) => WorkoutDetailScreen(id: widget.id), + name: 'WorkoutDetailScreen'), ), child: const Text('Full breakdown'), ), @@ -1669,6 +1673,17 @@ class _GpsLiveMapViewState extends State { _followedCount = path.length; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || _userPanned) return; + // Guard against a zero-sized viewport: fitCamera() computes zoom as a + // ratio against the map's current rendered pixel size, and flutter_map + // only clamps NEGATIVE size, not exactly-zero. If this runs before the + // FlutterMap widget has actually laid out (a real race even inside a + // postFrameCallback, e.g. right as this screen appears), that division + // can produce a NaN/Infinite zoom that fitCamera() happily ASSIGNS + // without throwing — the crash only surfaces a frame later, async, when + // the tile layer next reacts to the camera and tries to use that zoom. + // The try/catch below can't catch that; this check prevents it instead. + final size = _map.camera.nonRotatedSize; + if (size.x <= 0 || size.y <= 0) return; // next fix retries try { _map.fitCamera( CameraFit.bounds( diff --git a/lib/ui/ai/ai_breakdown_screen.dart b/lib/ui/ai/ai_breakdown_screen.dart index a41236a6..dc5ccc75 100644 --- a/lib/ui/ai/ai_breakdown_screen.dart +++ b/lib/ui/ai/ai_breakdown_screen.dart @@ -127,8 +127,9 @@ class _AiBreakdownScreenState extends State { 'provider, with your own key.', actionLabel: 'Add your AI key', onAction: () async { - await Navigator.of(context) - .push(themedRoute((_) => const CoachSettingsScreen())); + await Navigator.of(context).push(themedRoute( + (_) => const CoachSettingsScreen(), + name: 'CoachSettingsScreen')); if (mounted) _load(); // re-check on return }, ).dsEnter(), diff --git a/lib/ui/ai/ai_settings_screen.dart b/lib/ui/ai/ai_settings_screen.dart index 03c22c44..a48e91bd 100644 --- a/lib/ui/ai/ai_settings_screen.dart +++ b/lib/ui/ai/ai_settings_screen.dart @@ -182,8 +182,9 @@ class _AiSettingsScreenState extends State { final ok = cfg.configured; return SurfaceCard( padding: const EdgeInsets.symmetric(horizontal: Sp.x4, vertical: Sp.x2), - onTap: () => Navigator.of(context) - .push(themedRoute((_) => const CoachSettingsScreen())), + onTap: () => Navigator.of(context).push(themedRoute( + (_) => const CoachSettingsScreen(), + name: 'CoachSettingsScreen')), child: ListRow( icon: ok ? OsIcon.check : OsIcon.ai, // Spark art only in the "add a key" state — the connected state keeps diff --git a/lib/ui/coach/ai_coach_screen.dart b/lib/ui/coach/ai_coach_screen.dart index 25b36c70..f65a6232 100644 --- a/lib/ui/coach/ai_coach_screen.dart +++ b/lib/ui/coach/ai_coach_screen.dart @@ -254,8 +254,9 @@ class _AiCoachScreenState extends State { }); } - void _openSettings() => Navigator.of(context) - .push(themedRoute((_) => const CoachSettingsScreen())); + void _openSettings() => Navigator.of(context).push(themedRoute( + (_) => const CoachSettingsScreen(), + name: 'CoachSettingsScreen')); @override Widget build(BuildContext context) { diff --git a/lib/ui/insights/coach_cards.dart b/lib/ui/insights/coach_cards.dart index 24907d1f..7add2d61 100644 --- a/lib/ui/insights/coach_cards.dart +++ b/lib/ui/insights/coach_cards.dart @@ -105,6 +105,12 @@ class _SleepCoachCardState extends State { @override Widget build(BuildContext context) { + // Rebuild only on the 3 alarm fields _alarmCaption reads — was two + // separate context.watch() calls below (each one subscribing + // to ALL 67 notifyListeners() sources, not just alarm state). + context.select( + (a) => (a.alarmEpoch, a.alarmConfirmed, a.alarmPending), + ); if (_loading) return const SizedBox.shrink(); final need = _val(_coach?['need']); if (need == null) { @@ -167,9 +173,9 @@ class _SleepCoachCardState extends State { label: Text('Set band alarm for ${_hhmm(wakeMin)}'), ), ), - if (_alarmCaption(context.watch()) != null) ...[ + if (_alarmCaption(context.read()) != null) ...[ const SizedBox(height: Sp.x2), - Text(_alarmCaption(context.watch())!, + Text(_alarmCaption(context.read())!, style: AppText.captionMuted), ], ], diff --git a/lib/ui/journal/journal_compose_screen.dart b/lib/ui/journal/journal_compose_screen.dart index 9cb78abd..d70aca9c 100644 --- a/lib/ui/journal/journal_compose_screen.dart +++ b/lib/ui/journal/journal_compose_screen.dart @@ -288,8 +288,9 @@ class _JournalComposeScreenState extends State { 'Add your AI key to talk through your day and have it logged ' 'for you. Quick log works without one.', actionLabel: 'Add your AI key', - onAction: () => Navigator.of(context) - .push(themedRoute((_) => const CoachSettingsScreen())), + onAction: () => Navigator.of(context).push(themedRoute( + (_) => const CoachSettingsScreen(), + name: 'CoachSettingsScreen')), ).dsEnter(index: 2), ]; } diff --git a/lib/ui/journal/journal_screen.dart b/lib/ui/journal/journal_screen.dart index fa421bd9..e1677cf8 100644 --- a/lib/ui/journal/journal_screen.dart +++ b/lib/ui/journal/journal_screen.dart @@ -159,7 +159,8 @@ class _JournalScreenState extends State { RoundIconButton( OsIcon.ai, onTap: () => Navigator.of(context) - .push(themedRoute((_) => const JournalComposeScreen())) + .push(themedRoute((_) => const JournalComposeScreen(), + name: 'JournalComposeScreen')) .then((_) => _load()), ), ], diff --git a/lib/ui/onboarding/welcome_screen.dart b/lib/ui/onboarding/welcome_screen.dart index 638ef463..4005cbce 100644 --- a/lib/ui/onboarding/welcome_screen.dart +++ b/lib/ui/onboarding/welcome_screen.dart @@ -171,8 +171,8 @@ class _WelcomeScreenState extends State { icon: OsIcon.history, title: 'Import from a file', body: 'A backup from this app, or an export from another one.', - onTap: () => - Navigator.of(context).push(themedRoute((_) => const ImportScreen())), + onTap: () => Navigator.of(context).push( + themedRoute((_) => const ImportScreen(), name: 'ImportScreen')), ), const SizedBox(height: Sp.x3), WelcomeOptionCard( diff --git a/lib/ui/profile/advanced_data_screen.dart b/lib/ui/profile/advanced_data_screen.dart index 70a7dfc0..a7af82e2 100644 --- a/lib/ui/profile/advanced_data_screen.dart +++ b/lib/ui/profile/advanced_data_screen.dart @@ -131,7 +131,13 @@ class _AdvancedDataScreenState extends State { @override Widget build(BuildContext context) { - final app = context.watch(); + // Was context.watch() — the same reanalyzing/reanalyzeProgress + // pair as today_screen.dart's dev-tools card; this screen IS the "analyze + // your data" dev tool, so this select matters exactly during a backfill. + context.select( + (a) => (a.reanalyzing, a.reanalyzeProgress), + ); + final app = context.read(); return Scaffold( backgroundColor: AppColors.bg, body: SafeArea( diff --git a/lib/ui/profile/notification_relay_section.dart b/lib/ui/profile/notification_relay_section.dart index 9774ccd5..37bc2075 100644 --- a/lib/ui/profile/notification_relay_section.dart +++ b/lib/ui/profile/notification_relay_section.dart @@ -37,8 +37,9 @@ class NotificationRelaySection extends StatelessWidget { return SurfaceCard( padding: const EdgeInsets.symmetric(horizontal: Sp.x4, vertical: Sp.x2), - onTap: () => Navigator.of(context).push( - themedRoute((_) => const NotificationRelayScreen())), + onTap: () => Navigator.of(context).push(themedRoute( + (_) => const NotificationRelayScreen(), + name: 'NotificationRelayScreen')), child: ListRow( icon: OsIcon.notifications, iconColor: AppColors.accent, diff --git a/lib/ui/profile/profile_screen.dart b/lib/ui/profile/profile_screen.dart index 8e2fffff..0431efe5 100644 --- a/lib/ui/profile/profile_screen.dart +++ b/lib/ui/profile/profile_screen.dart @@ -52,6 +52,16 @@ class ProfileScreen extends StatelessWidget { @override Widget build(BuildContext context) { + // REVERTED to watch(): this class reads 12+ AppState fields across many + // private helper methods (_deviceTile, the privacy toggles, companion + // config, etc.) spread over 600 lines. A prior attempt at scoping this to + // context.select() only covered 4 of those 12 fields — telemetryConsent + // wasn't one of them, so toggling it called notifyListeners() but this + // screen no longer rebuilt on that signal, leaving the switch showing its + // stale value (looked like the toggle "automatically turning back on"). + // Profile is a low-frequency settings screen, not part of the + // scrolling/live-workout hot path the notifyListeners() storm actually + // hurt — the safety of watch() here is worth more than the optimization. final app = context.watch(); final units = context.watch(); final user = app.user ?? const {}; @@ -141,6 +151,7 @@ class ProfileScreen extends StatelessWidget { (_) => StepGoalScreen( goal: (user['step_goal'] as num?)?.toInt(), ), + name: 'StepGoalScreen', ), ), ), @@ -158,7 +169,7 @@ class ProfileScreen extends StatelessWidget { divider: true, onTap: () => Navigator.of( context, - ).push(themedRoute((_) => const ImportScreen())), + ).push(themedRoute((_) => const ImportScreen(), name: 'ImportScreen')), ), ListRow( icon: OsIcon.sync, @@ -230,7 +241,8 @@ class ProfileScreen extends StatelessWidget { divider: advancedDebugMode, onTap: () => Navigator.of( context, - ).push(themedRoute((_) => const DataHistoryScreen())), + ).push(themedRoute((_) => const DataHistoryScreen(), + name: 'DataHistoryScreen')), ), // Debug-build-only deep inspection tools (raw stores, sync ledger). if (advancedDebugMode) @@ -240,7 +252,8 @@ class ProfileScreen extends StatelessWidget { value: 'Debug tools', onTap: () => Navigator.of( context, - ).push(themedRoute((_) => const AdvancedDataScreen())), + ).push(themedRoute((_) => const AdvancedDataScreen(), + name: 'AdvancedDataScreen')), ), ]), @@ -345,7 +358,8 @@ class ProfileScreen extends StatelessWidget { value: 'Manage', onTap: () => Navigator.of( context, - ).push(themedRoute((_) => const NotificationSettingsScreen())), + ).push(themedRoute((_) => const NotificationSettingsScreen(), + name: 'NotificationSettingsScreen')), ), ]), // Notification relay (Android only — self-hides on iOS). @@ -362,8 +376,8 @@ class ProfileScreen extends StatelessWidget { icon: OsIcon.ai, title: 'Briefings & journal', value: 'Manage', - onTap: () => Navigator.of(context) - .push(themedRoute((_) => const AiSettingsScreen())), + onTap: () => Navigator.of(context).push(themedRoute( + (_) => const AiSettingsScreen(), name: 'AiSettingsScreen')), ), ]), const SizedBox(height: Sp.x6), @@ -428,8 +442,9 @@ class ProfileScreen extends StatelessWidget { icon: OsIcon.edit, title: 'Design gallery', value: 'All components', - onTap: () => Navigator.of(context) - .push(themedRoute((_) => const DesignGalleryScreen())), + onTap: () => Navigator.of(context).push(themedRoute( + (_) => const DesignGalleryScreen(), + name: 'DesignGalleryScreen')), ), ]), @@ -1176,8 +1191,15 @@ class _DeviceSheet extends StatelessWidget { @override Widget build(BuildContext context) { - // Rebuild when device state changes (alarm/name/connection). - final live = context.watch(); + // Rebuild when device state changes (alarm/name/connection) — was a + // blanket watch() before; select the fields actually used instead. (Prior + // pass here missed `device`/`paired` — re-audited against every `live.` + // touchpoint in this class after finding the same gap cost a real bug in + // the main ProfileScreen build above.) + context.select( + (a) => (a.isConnected, a.alarmEpoch, a.strapName, a.device, a.paired), + ); + final live = context.read(); final connected = live.isConnected; final alarm = live.alarmEpoch; diff --git a/lib/ui/screens/screens.dart b/lib/ui/screens/screens.dart index c81fbc06..362f98fc 100644 --- a/lib/ui/screens/screens.dart +++ b/lib/ui/screens/screens.dart @@ -268,8 +268,11 @@ class _ActivityDetailState extends State<_ActivityDetail> { final live = _isToday ? context.select((a) => a.liveSteps) : 0; - final app = context.watch(); - final goal = (app.user?['step_goal'] as num?)?.toInt(); + // Was context.watch() — rebuilt this whole board on every one of + // AppState's 67 notifyListeners() sources. Only `user` (for step_goal) is + // actually read below. + final user = context.select?>((a) => a.user); + final goal = (user?['step_goal'] as num?)?.toInt(); return StepsDayContent( steps: (_steps?.round() ?? 0) + live, goal: goal, diff --git a/lib/ui/screens/trend_screen.dart b/lib/ui/screens/trend_screen.dart index c657155b..193325dd 100644 --- a/lib/ui/screens/trend_screen.dart +++ b/lib/ui/screens/trend_screen.dart @@ -26,6 +26,7 @@ void openTrend( Navigator.of(context).push(themedRoute((_) => GenericTrendScreen( title: title, metric: metric, icon: icon, accent: accent, valueFmt: valueFmt), + name: 'GenericTrendScreen:$metric', )); } diff --git a/lib/ui/sleep/sleep_detail_screen.dart b/lib/ui/sleep/sleep_detail_screen.dart index 006f41c3..a4d1cb39 100644 --- a/lib/ui/sleep/sleep_detail_screen.dart +++ b/lib/ui/sleep/sleep_detail_screen.dart @@ -259,7 +259,8 @@ class _SleepDetailScreenState extends State { RoundIconButton( OsIcon.bedtime, onTap: () => Navigator.of(context).push( - themedRoute((_) => SleepPeriodsScreen(date: widget.date)), + themedRoute((_) => SleepPeriodsScreen(date: widget.date), + name: 'SleepPeriodsScreen'), ), ), ], diff --git a/lib/ui/spotcheck/spot_check_screen.dart b/lib/ui/spotcheck/spot_check_screen.dart index 178c1af9..ad4713ef 100644 --- a/lib/ui/spotcheck/spot_check_screen.dart +++ b/lib/ui/spotcheck/spot_check_screen.dart @@ -18,19 +18,27 @@ class SpotCheckScreen extends StatelessWidget { @override Widget build(BuildContext context) { - final app = context.watch(); - final active = app.spotActive; - final remaining = app.spotRemaining; + // Was context.watch() — rebuilt on all 67 notifyListeners() + // sources even while idle. Selecting the 6 fields actually rendered means + // this only rebuilds on real spot-check state changes (still every + // second WHILE a scan is active — that's correct, the countdown needs it) + // instead of also on unrelated BLE drains/derive passes/other timers. + final (connected, active, remaining, liveHr, result, error) = + context.select?, String?)>( + (a) => (a.isConnected, a.spotActive, a.spotRemaining, a.device.liveHr, + a.spotResult, a.spotError), + ); + final app = context.read(); return SpotCheckView( - connected: app.isConnected, + connected: connected, active: active, remaining: remaining, progress: active ? (AppState.spotDuration - remaining) / AppState.spotDuration : 0.0, - liveHr: app.device.liveHr, - result: app.spotResult, - error: app.spotError, + liveHr: liveHr, + result: result, + error: error, onStart: app.startSpotCheck, onCancel: app.cancelSpotCheck, onBack: () { diff --git a/lib/ui/stress/calm_breathing_screen.dart b/lib/ui/stress/calm_breathing_screen.dart index b67e18ff..f8823605 100644 --- a/lib/ui/stress/calm_breathing_screen.dart +++ b/lib/ui/stress/calm_breathing_screen.dart @@ -28,7 +28,12 @@ class CalmBreathingScreen extends StatelessWidget { @override Widget build(BuildContext context) { - final app = context.watch(); + // Was context.watch() — select the 4 fields actually rendered + // instead of rebuilding on all 67 notifyListeners() sources. + context.select?, String?)>( + (a) => (a.isConnected, a.breathingActive, a.breathingResult, a.breathingError), + ); + final app = context.read(); if (autoStart && !app.breathingActive && app.isConnected) { // Guarded by breathingActive so this only ever fires once per mount — // startBreathingSession() flips breathingActive true and notifies, diff --git a/lib/ui/today/today_screen.dart b/lib/ui/today/today_screen.dart index 57d8c819..33625b6c 100644 --- a/lib/ui/today/today_screen.dart +++ b/lib/ui/today/today_screen.dart @@ -124,7 +124,18 @@ class _TodayScreenState extends State @override Widget build(BuildContext context) { - final app = context.watch(); + // SELECT the 3 fields _emptyOrProcessing actually reads, not the whole + // AppState — this screen used to fully rebuild on EVERY notifyListeners() + // (67 call sites incl. per-second timers and every derive-day callback), + // which is exactly what made a multi-day backfill/reanalyze visibly + // freeze this screen (several notifications in quick succession, each one + // forcing a full ListView rebuild while a screen switch might also be + // in flight). `app` itself is still the live, same-instance object (read, + // not watch) — only the REBUILD TRIGGER is now scoped. + context.select, bool, String)>( + (a) => (a.dbCounts, a.reanalyzing, a.reanalyzeProgress), + ); + final app = context.read(); final t = TodayData.fromJson(data); return AppScaffold( @@ -195,9 +206,14 @@ class _TodayScreenState extends State } // Builder-based so themedRoute reconstructs the screen on a theme flip (a - // prebuilt instance would be returned unchanged and never re-colour). - void _push(Widget Function() build) => - Navigator.of(context).push(themedRoute((_) => build())); + // prebuilt instance would be returned unchanged and never re-colour). We + // still call build() once up front, throwaway, just to read its runtime + // type for the route name (current_screen on a crash/ANR report) — the + // real navigation still goes through the fresh builder each time. + void _push(Widget Function() build) { + final name = build().runtimeType.toString(); + Navigator.of(context).push(themedRoute((_) => build(), name: name)); + } // ── content ────────────────────────────────────────────────────────────────── diff --git a/lib/ui/widgets/status_banner.dart b/lib/ui/widgets/status_banner.dart index 60721648..9ed6e400 100644 --- a/lib/ui/widgets/status_banner.dart +++ b/lib/ui/widgets/status_banner.dart @@ -20,7 +20,15 @@ class StatusBanner extends StatelessWidget { @override Widget build(BuildContext context) { - final app = context.watch(); + // Was context.watch() — this sits at the top of Today's ListView + // (rendered on every visit), so rebuilding it on all 67 notifyListeners() + // sources mattered a lot. activeBanner/updateAvailable/update/mandatory + // change rarely (an admin push or an OTA check), unlike almost everything + // else in AppState. + context.select( + (a) => (a.activeBanner, a.updateAvailable, a.update, a.updateMandatory), + ); + final app = context.read(); final banner = app.activeBanner; final showUpdate = app.updateAvailable; if (banner == null && !showUpdate) return const SizedBox.shrink(); diff --git a/lib/ui/workouts/workouts_screen.dart b/lib/ui/workouts/workouts_screen.dart index 014af7e0..dca2d28e 100644 --- a/lib/ui/workouts/workouts_screen.dart +++ b/lib/ui/workouts/workouts_screen.dart @@ -109,7 +109,8 @@ Future startWorkoutFlow(BuildContext context) async { app.startWorkout(workoutId: id, type: type); Navigator.of( context, - ).push(themedRoute((_) => LiveSessionScreen(workoutId: id, type: type))); + ).push(themedRoute((_) => LiveSessionScreen(workoutId: id, type: type), + name: 'LiveSessionScreen')); } catch (_) { /* surfaced as no-op; user can retry */ } @@ -374,7 +375,8 @@ class _WorkoutsScreenState extends State { mostSteps: mostSteps, entranceIndex: index, onTap: () => Navigator.of(context).push( - themedRoute((_) => WorkoutDetailScreen(id: w['id'] as String)), + themedRoute((_) => WorkoutDetailScreen(id: w['id'] as String), + name: 'WorkoutDetailScreen'), ), onLongPress: w['status'] == 'live' ? null : () => _exportCard(w), ), @@ -425,6 +427,7 @@ class _WorkoutsScreenState extends State { Navigator.of(context).push( themedRoute( (_) => WorkoutFinishScreen(id: w['id'] as String, snapshot: snap), + name: 'WorkoutFinishScreen', ), ); }