From ac576208eadb0a7651329f253fcb0311d6553006 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 16 Jul 2026 23:19:08 +0530 Subject: [PATCH 1/2] fix: stop the app freezing during backfills, add real crash/jank visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two separate problems, found by actually wiring the app up to Firebase. The freeze/hang: derivation_engine.dart's cross-day rollup reads up to 90 days of stored results, decodes each one, rebuilds a cross-day record for it, and re-encodes the result — none of that ran off the main thread. On an older phone this blocked the UI for several seconds during a heavy derive pass. Moved the decode/transform/encode work into Isolate.run (the two helper functions it uses were already static, so this was a clean move) and did the same for the crossday bundle's own jsonEncode, which was happening back on the main isolate after the isolate call returned. The rebuild storm: several screens (Today, Steps, Spot Check, Profile's device sheet, breathing, the status banner, coach cards) used a blanket context.watch() and rebuilt on all 67 of AppState's notifyListeners() calls, including three separate per-second timers. Switched these to context.select() scoped to the fields each screen actually reads. Reverted the same change on the main Profile screen after it broke the telemetry toggle — that screen touches too many fields across too many helper methods to track by hand safely, and it isn't one of the screens background timers actually hit, so watch() stays there. Also fixed a live-session map crash: fitCamera() could get called before the map had a real rendered size, and flutter_map's zoom math divides by that size — a zero-size viewport can produce a NaN/Infinite zoom that doesn't throw until the tile layer tries to use it a frame later. Added a guard on the viewport size before calling fitCamera. Added actual observability: Crashlytics breadcrumbs on navigation and derive passes, custom keys for screen/derive state, a frame-timing watchdog that reports slow frames as non-fatal issues (this is what caught the hang above), and a readiness diagnostic that records which of HRV/RHR/resp/temp were missing when the readiness score comes back empty. flutter analyze: clean. flutter test: 457/457. --- lib/app.dart | 11 ++ lib/compute/derivation_engine.dart | 68 +++++++--- lib/compute/onehz_pipeline.dart | 18 +++ lib/main.dart | 4 + lib/state/app_state.dart | 21 +++- lib/telemetry/telemetry_service.dart | 154 +++++++++++++++++++++++ lib/ui/activity/live_session_screen.dart | 15 ++- lib/ui/insights/coach_cards.dart | 10 +- lib/ui/profile/advanced_data_screen.dart | 8 +- lib/ui/profile/profile_screen.dart | 21 +++- lib/ui/screens/screens.dart | 7 +- lib/ui/spotcheck/spot_check_screen.dart | 22 ++-- lib/ui/stress/calm_breathing_screen.dart | 7 +- lib/ui/today/today_screen.dart | 13 +- lib/ui/widgets/status_banner.dart | 10 +- 15 files changed, 353 insertions(+), 36 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index e621ecca..9a286265 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(); diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 3212d6f9..3823811c 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,25 @@ 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'); + TelemetryService.instance.record( + kind: 'event', + level: 'warn', + message: 'readiness_absent', + context: (absentDiag as Map).cast(), + ); + } // 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 +1736,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 +1775,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/ui/activity/live_session_screen.dart b/lib/ui/activity/live_session_screen.dart index 46105a84..9d9e0467 100644 --- a/lib/ui/activity/live_session_screen.dart +++ b/lib/ui/activity/live_session_screen.dart @@ -725,7 +725,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: ''), ]), ]), @@ -1669,6 +1671,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/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/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/profile_screen.dart b/lib/ui/profile/profile_screen.dart index 8e2fffff..5921fd1b 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 {}; @@ -1176,8 +1186,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/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..be3b290f 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( 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(); From fac5117aa0d682b3d2e04818c2fd8dcc203541f1 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 16 Jul 2026 23:52:44 +0530 Subject: [PATCH 2/2] fix: address CodeRabbit review on PR #85 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things it flagged, both real: The readiness-absent diagnostic was passing a nested map straight into TelemetryService.record(), which only keeps num/String context values as-is and stringifies everything else. So each of HRV/RHR/resp/temp would've shown up in Firebase Analytics as one unqueryable "{value: true, baseline_n: 5}"-style string instead of separate filterable fields. Flattened it into hrv_value/hrv_baseline_n/etc. current_screen (the custom key TelemetryNavigatorObserver sets on push) falls back to the route's runtime type when there's no RouteSettings name, and nothing in this codebase was naming its routes — so in practice every crash/ANR report would just say something like "MaterialPageRoute" instead of which screen it actually happened on. Added an optional name parameter to themedRoute() and passed a real name at all 24 push sites across the app. Today screen's shared _push() helper (used by 15 of those) now reads the destination widget's runtime type once before pushing, so its many callers didn't need touching individually. flutter analyze: clean. flutter test: 457/457. --- lib/app.dart | 6 ++++-- lib/compute/derivation_engine.dart | 16 +++++++++++++- lib/theme/theme_switcher.dart | 7 +++++++ lib/ui/activity/live_session_screen.dart | 6 ++++-- lib/ui/ai/ai_breakdown_screen.dart | 5 +++-- lib/ui/ai/ai_settings_screen.dart | 5 +++-- lib/ui/coach/ai_coach_screen.dart | 5 +++-- lib/ui/journal/journal_compose_screen.dart | 5 +++-- lib/ui/journal/journal_screen.dart | 3 ++- lib/ui/onboarding/welcome_screen.dart | 4 ++-- .../profile/notification_relay_section.dart | 5 +++-- lib/ui/profile/profile_screen.dart | 21 ++++++++++++------- lib/ui/screens/trend_screen.dart | 1 + lib/ui/sleep/sleep_detail_screen.dart | 3 ++- lib/ui/today/today_screen.dart | 11 +++++++--- lib/ui/workouts/workouts_screen.dart | 7 +++++-- 16 files changed, 78 insertions(+), 32 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 9a286265..6a7d8d74 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -202,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, @@ -346,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 3823811c..511e337b 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -1377,11 +1377,25 @@ class DerivationEngine { _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: (absentDiag as Map).cast(), + context: flat, ); } 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 9d9e0467..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(); @@ -1449,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'), ), 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/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/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 5921fd1b..0431efe5 100644 --- a/lib/ui/profile/profile_screen.dart +++ b/lib/ui/profile/profile_screen.dart @@ -151,6 +151,7 @@ class ProfileScreen extends StatelessWidget { (_) => StepGoalScreen( goal: (user['step_goal'] as num?)?.toInt(), ), + name: 'StepGoalScreen', ), ), ), @@ -168,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, @@ -240,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) @@ -250,7 +252,8 @@ class ProfileScreen extends StatelessWidget { value: 'Debug tools', onTap: () => Navigator.of( context, - ).push(themedRoute((_) => const AdvancedDataScreen())), + ).push(themedRoute((_) => const AdvancedDataScreen(), + name: 'AdvancedDataScreen')), ), ]), @@ -355,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). @@ -372,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), @@ -438,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')), ), ]), 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/today/today_screen.dart b/lib/ui/today/today_screen.dart index be3b290f..33625b6c 100644 --- a/lib/ui/today/today_screen.dart +++ b/lib/ui/today/today_screen.dart @@ -206,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/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', ), ); }