diff --git a/PRIVACY.md b/PRIVACY.md
index 86c16199..081d0f16 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -1,6 +1,6 @@
# Privacy Policy — Edge / OpenStrap
-_Last updated: July 20, 2026_
+_Last updated: July 27, 2026_
Edge ("the App") is an independent, open-source project. It is not affiliated
with, sponsored by, or endorsed by WHOOP, Inc.
@@ -43,6 +43,39 @@ is handled under Firebase's own privacy and security practices, not a system
we built or operate ourselves — see Google's Firebase privacy & security
documentation: https://firebase.google.com/support/privacy.
+**Location and workout routes**
+If you record a run, ride or walk, the App uses your device's location to draw
+that workout's route. This is the most sensitive permission the App asks for,
+so to be specific about it:
+
+- **Only during a workout.** Location is read only while a run, ride or walk is
+ actively recording. It stops the moment you finish. The App never reads your
+ location in the background at any other time.
+- **We never ask for "always" access.** The App requests *while-in-use*
+ location only. Recording does continue while your screen is locked or you
+ switch apps — otherwise a workout would stop being recorded the moment you
+ put your phone in your pocket — but that is scoped to the active workout, not
+ a standing permission to follow you.
+- **It is visible while it happens.** On iOS the system's blue location
+ indicator is shown for the whole time the App is reading location in the
+ background. On Android the workout runs as a foreground service with a
+ visible, persistent notification.
+- **The App never sends your routes anywhere.** A route is written to a local
+ database table on your phone and nowhere else. We do not upload it, it is not
+ included in anonymous diagnostics, and it is not sent to your AI Coach
+ provider — the coach is technically prevented from reading route data, not
+ merely asked not to.
+- **The one exception is you.** If you tap Share on a workout, the image you
+ are shown includes a picture of your route, and whatever you send it to
+ receives it. That is your choice, you see the image before it is sent, and it
+ goes wherever you send it — not to us.
+- **You can delete it.** Deleting a workout deletes its route with it, and
+ uninstalling the App removes all of it immediately.
+
+You can decline or revoke location access at any time in your device settings.
+The App still records the workout — heart rate, duration, strain and the rest —
+it simply has no map for it.
+
**Optional, user-initiated integrations**
If you choose to enable them, the App can also send data to services *you*
configure:
diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/EdgeTrackingService.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/EdgeTrackingService.kt
index c0223ef3..06088bd1 100644
--- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/EdgeTrackingService.kt
+++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/EdgeTrackingService.kt
@@ -41,6 +41,31 @@ class EdgeTrackingService : Service() {
*/
const val EXTRA_LOCATION = "location"
+ /**
+ * Sticky "a GPS route session is live in this process" flag.
+ *
+ * WHY THIS EXISTS: several native callers restart the service WITHOUT
+ * going through Dart — CompanionBridge.onDeviceAppeared (fires whenever
+ * the band re-enters BLE range, which happens routinely mid-run from
+ * arm-swing/body-block dropouts), KeepAliveWorker and BootReceiver.
+ * They use [start] below, whose Intent carries no EXTRA_LOCATION, so
+ * onStartCommand used to read `false` and re-call startForeground()
+ * with CONNECTED_DEVICE only — silently STRIPPING the location type off
+ * a live workout. On Android 14+ that ends location delivery the next
+ * time the app is backgrounded and the route just stops mid-ride, with
+ * no crash and no log.
+ *
+ * So the extra is now tri-state: present ⇒ authoritative (and latched
+ * here), absent ⇒ inherit whatever the live session last asked for.
+ * A process kill resets this to false, which is correct — Dart re-arms
+ * it via EdgeTracking.start(location: true) when it rehydrates the
+ * orphaned workout.
+ */
+ @Volatile
+ @JvmStatic
+ var locationSessionActive: Boolean = false
+ private set
+
/**
* True while the service is alive IN THIS PROCESS. The KeepAliveWorker runs
* in the same process, so this is an exact "is my service running" check —
@@ -72,12 +97,25 @@ class EdgeTrackingService : Service() {
override fun onDestroy() {
running = false
+ // The latch is per-process and per-service-lifetime; a fresh service
+ // must not inherit a stale "route session live" claim.
+ locationSessionActive = false
super.onDestroy()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val notif = buildNotification()
- val withLocation = intent?.getBooleanExtra(EXTRA_LOCATION, false) == true
+ // Tri-state (see [locationSessionActive]): only an intent that actually
+ // carries the extra may change the mode. A bare start() from CDM /
+ // KeepAliveWorker / boot inherits the live session's type instead of
+ // downgrading it.
+ val withLocation = if (intent?.hasExtra(EXTRA_LOCATION) == true) {
+ intent.getBooleanExtra(EXTRA_LOCATION, false).also {
+ locationSessionActive = it
+ }
+ } else {
+ locationSessionActive
+ }
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
var type = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt
index eecaae8e..ecbe0e45 100644
--- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt
+++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt
@@ -20,6 +20,7 @@ import android.os.Vibrator
import android.os.VibratorManager
import android.provider.Settings
import android.view.KeyEvent
+import android.view.WindowManager
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
@@ -69,6 +70,32 @@ object NativeChannels {
app.stopService(Intent(app, EdgeTrackingService::class.java))
result.success(null)
}
+ // Hold the screen on for the duration of a live workout, the
+ // way every run/ride app does — the athlete is glancing at a
+ // handlebar/armband, not tapping to keep the display awake.
+ // FLAG_KEEP_SCREEN_ON is scoped to this window and released
+ // automatically if the activity goes away, so it can never
+ // leak into a permanent wakelock.
+ "keepAwake" -> {
+ val on = call.argument("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)
+ }
+ }
"consumeHeadlessBootPending" -> {
val prefs = app.getSharedPreferences(
"openstrap_runtime",
diff --git a/docs/privacy.html b/docs/privacy.html
index 2a87c3d0..c3940f6b 100644
--- a/docs/privacy.html
+++ b/docs/privacy.html
@@ -22,7 +22,7 @@
Privacy Policy — Edge / OpenStrap
- Last updated: July 20, 2026
+ Last updated: July 27, 2026
Edge ("the App") is an independent, open-source project. It is not affiliated
with, sponsored by, or endorsed by WHOOP, Inc.
@@ -66,6 +66,42 @@ Anonymous diagnostics
Google's Firebase privacy & security documentation:
firebase.google.com/support/privacy.
+ Location and workout routes
+ If you record a run, ride or walk, the App uses your device's location to
+ draw that workout's route. This is the most sensitive permission the App
+ asks for, so to be specific about it:
+
+ - Only during a workout. Location is read only while a
+ run, ride or walk is actively recording. It stops the moment you finish.
+ The App never reads your location in the background at any other time.
+ - We never ask for "always" access. The App requests
+ while-in-use location only. Recording does continue while your
+ screen is locked or you switch apps — otherwise a workout would stop
+ being recorded the moment you put your phone in your pocket — but that
+ is scoped to the active workout, not a standing permission to follow
+ you.
+ - It is visible while it happens. On iOS the system's
+ blue location indicator is shown for the whole time the App is reading
+ location in the background. On Android the workout runs as a foreground
+ service with a visible, persistent notification.
+ - The App never sends your routes anywhere. A route is
+ written to a local database table on your phone and nowhere else. We do
+ not upload it, it is not included in anonymous diagnostics, and it is
+ not sent to your AI Coach provider — the coach is technically prevented
+ from reading route data, not merely asked not to.
+ - The one exception is you. If you tap Share on a
+ workout, the image you are shown includes a picture of your route, and
+ whatever you send it to receives it. That is your choice, you see the
+ image before it is sent, and it goes wherever you send it — not to
+ us.
+ - You can delete it. Deleting a workout deletes its
+ route with it, and uninstalling the App removes all of it
+ immediately.
+
+ You can decline or revoke location access at any time in your device
+ settings. The App still records the workout — heart rate, duration, strain
+ and the rest — it simply has no map for it.
+
Optional, user-initiated integrations
If you choose to enable them, the App can also send data to services
you configure:
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index 08ebad26..56d3e815 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -111,6 +111,15 @@ enum ConfigBridge {
// the paired Apple Watch. Best-effort, never fails the Dart caller.
WatchBridge.shared.pushCurrentState()
result(true)
+ case "keepAwake":
+ // Hold the display awake for a live workout, the way every run/ride app
+ // does. Scoped strictly to the session: Dart clears it on finish, and
+ // iOS drops it anyway if the app is terminated, so it cannot leak into
+ // a permanently-awake screen.
+ let args = call.arguments as? [String: Any] ?? [:]
+ let on = args["on"] as? Bool ?? false
+ UIApplication.shared.isIdleTimerDisabled = on
+ result(true)
default:
result(FlutterMethodNotImplemented)
}
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 3cfe2647..bbbd94d6 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -68,15 +68,19 @@
NSBluetoothPeripheralUsageDescription
OpenStrap connects to your WHOOP band over Bluetooth to sync your health data.
NSLocationWhenInUseUsageDescription
- OpenStrap records your route on a map during a run, ride or walk so you can see it, colored by heart-rate zone, when the workout ends. Your location stays on this device and is never uploaded.
-
+ OpenStrap records your route during a run, ride or walk so you can see it, colored by heart-rate zone, when the workout ends. Recording continues while your screen is locked or you switch apps, but only while a workout is running — it stops the moment you finish. Your route stays on this device and is never uploaded.
+
NSLocationAlwaysAndWhenInUseUsageDescription
- OpenStrap does not track your location in the background. This permission is linked by a dependency but unused — the app only ever asks for location access while you're actively viewing a workout route.
+ OpenStrap never needs always-on location and does not ask for it. It records your route only while a run, ride or walk is actively running — including when your screen is locked — and stops as soon as you finish. Your route stays on this device and is never uploaded.
BGTaskSchedulerPermittedIdentifiers
@@ -129,6 +144,7 @@
UIBackgroundModes
bluetooth-central
+ location
processing
fetch
diff --git a/lib/ai/briefing_engine.dart b/lib/ai/briefing_engine.dart
index bf1c5a7d..ff49e9a6 100644
--- a/lib/ai/briefing_engine.dart
+++ b/lib/ai/briefing_engine.dart
@@ -153,7 +153,7 @@ String partOfDay(DateTime now) {
///
/// THE single source of truth for readiness-score banding — also used by
/// the Today ring's status word (`TodayVitals._orbitHero` in
-/// today_screen.dart maps good/moderate/low → Primed/Steady/Run easy).
+/// today_screen.dart maps good/moderate/low → Push/Focus/Recover).
/// These cuts (40/66) MUST match the ring's own thresholds: a briefing band
/// computed from different cuts than the ring's word is exactly the
/// tone-vs-score contradiction this function exists to prevent, just moved
diff --git a/lib/compute/derive_scheduler.dart b/lib/compute/derive_scheduler.dart
index 87c8a4a7..d7f15375 100644
--- a/lib/compute/derive_scheduler.dart
+++ b/lib/compute/derive_scheduler.dart
@@ -26,6 +26,19 @@ class DeriveScheduler {
final Duration heavySettle;
bool _offloadActive = false;
+
+ /// True while a live workout is running. Held exactly like [_offloadActive].
+ ///
+ /// Heavy derivation spawns an isolate (roughly doubling peak heap) and hits
+ /// the DB hard. Nothing used to stop that landing in the middle of a run or
+ /// ride — and the existing foreground/background gate is INVERTED for this
+ /// case: with the phone mounted on the bars and the screen awake the app IS
+ /// foregrounded, so derives ran at their most expensive possible moment,
+ /// competing with the GPS stream, the live map and the BLE drain. A workout
+ /// is minutes long and its own results are derived at the end anyway, so
+ /// deferring costs nothing.
+ bool _workoutActive = false;
+
// While the app is backgrounded we must NOT run derivation: a derive pass
// decodes the whole retained substrate + runs the metric compute, and doing
// that on a short background BLE wake trips iOS's CPU watchdog
@@ -54,6 +67,7 @@ class DeriveScheduler {
Map snapshot() => {
'offload_active': _offloadActive,
+ 'workout_active': _workoutActive,
'background': _background,
'running': _running,
'pending_light': _pendingLight,
@@ -68,6 +82,23 @@ class DeriveScheduler {
unawaited(_enqueue(type: 'derive_heavy', reason: 'capture_settled'));
}
+ /// Hold derivation for the duration of a live workout (see [_workoutActive]).
+ /// Queued jobs stay durable and drain the moment the session ends.
+ void setWorkoutActive(bool active) {
+ if (_workoutActive == active) return;
+ _workoutActive = active;
+ if (active) {
+ _timer?.cancel();
+ _timer = null;
+ log('[derive-scheduler] workout live — holding derive work');
+ onChanged();
+ return;
+ }
+ log('[derive-scheduler] workout ended — derive may run');
+ onChanged();
+ _arm();
+ }
+
void setOffloadActive(bool active) {
if (_offloadActive == active) return;
_offloadActive = active;
@@ -118,7 +149,7 @@ class DeriveScheduler {
}
void _arm() {
- if (_running || _offloadActive || _background) return;
+ if (_running || _offloadActive || _background || _workoutActive) return;
if (!_pendingLight && !_pendingHeavy) {
unawaited(_refreshSnapshot());
return;
@@ -131,7 +162,7 @@ class DeriveScheduler {
}
Future _drain() async {
- if (_running || _offloadActive || _background) return;
+ if (_running || _offloadActive || _background || _workoutActive) return;
_timer?.cancel();
_timer = null;
final job = await LocalDb.takeNextComputeJob();
@@ -140,6 +171,18 @@ class DeriveScheduler {
return;
}
final id = job['id']?.toString();
+ // RE-CHECK THE GATES AFTER ACQUISITION. The checks above happened before a
+ // DB round-trip, and a workout can start (or an offload/background flip can
+ // land) inside it — at which point running the pass is exactly what the
+ // gate exists to prevent. The job is already marked `running` by
+ // takeNextComputeJob, so hand it back rather than leaving it claimed.
+ if (_offloadActive || _background || _workoutActive) {
+ if (id != null && id.isNotEmpty) {
+ await LocalDb.requeueComputeJob(id);
+ }
+ await _refreshSnapshot();
+ return;
+ }
final kind = _parseKind(job['type']?.toString());
_running = true;
await _refreshSnapshot();
diff --git a/lib/data/db.dart b/lib/data/db.dart
index e00b63c1..cb528a42 100644
--- a/lib/data/db.dart
+++ b/lib/data/db.dart
@@ -3951,6 +3951,22 @@ class LocalDb {
});
}
+ /// Put a claimed job back on the queue without counting it as an attempt.
+ ///
+ /// Used when a gate closes DURING acquisition: `_drain()` clears the gate,
+ /// awaits [takeNextComputeJob], and by the time that returns a workout may
+ /// have started. The job is already marked `running`, so it has to be handed
+ /// back explicitly or it sits claimed until the next [recoverComputeJobs].
+ /// The attempt increment is undone too — being deferred is not a failure.
+ static Future requeueComputeJob(String id) async {
+ final db = await instance;
+ await db.rawUpdate(
+ 'UPDATE compute_jobs SET state = ?, '
+ 'attempts = MAX(attempts - 1, 0), updated_at = ? WHERE id = ?',
+ ['queued', DateTime.now().millisecondsSinceEpoch, id],
+ );
+ }
+
static Future completeComputeJob(String id) async {
final db = await instance;
await db.delete('compute_jobs', where: 'id = ?', whereArgs: [id]);
diff --git a/lib/gps/gps_source.dart b/lib/gps/gps_source.dart
index 0c8809b2..fb3bd261 100644
--- a/lib/gps/gps_source.dart
+++ b/lib/gps/gps_source.dart
@@ -3,10 +3,15 @@
// `GpsSample`s. Nothing here is uploaded: fixes flow only into the local
// RouteTracker → workout_route table.
//
-// v1 uses WHILE-IN-USE location. Continuous background ("always") location for
-// screen-off tracking is a documented follow-up (see Info.plist / manifest
-// notes); during a session the app is kept alive by the existing foreground
-// service, so fixes keep flowing while the app is foregrounded.
+// Authorization stays WHILE-IN-USE — "always" is never requested. Background
+// delivery during a workout does not need it: on iOS the "location"
+// UIBackgroundMode + allowsBackgroundLocationUpdates is enough (blue indicator
+// shown), and on Android the existing EdgeTrackingService claims the `location`
+// foreground-service type for the duration (EdgeTracking.start(location: true)).
+//
+// Both are armed ONLY while a route session is live. That is the difference
+// between a workout that survives a pocketed phone and one that silently dies
+// the moment the screen locks.
import 'dart:io' show Platform;
@@ -92,12 +97,18 @@ class GpsSource {
distanceFilter: distanceFilter,
activityType: ActivityType.fitness,
pauseLocationUpdatesAutomatically: false,
- // Deliberately NOT enabling background location updates in v1 — the
- // "location" UIBackgroundMode is intentionally absent. The live map UI
- // shows a "keep the screen on" hint; RouteTracker's gap recovery starts
- // a fresh segment when fixes resume after an unlock.
- allowBackgroundLocationUpdates: false,
- showBackgroundLocationIndicator: false,
+ // Background updates are the ONLY thing that keeps a workout alive
+ // across a screen lock or an app switch. Without this (and the
+ // matching "location" UIBackgroundMode) iOS suspends the process
+ // within seconds, the fix stream stops, and a suspended app is first
+ // in line for jetsam — which is what "the app closed mid-ride" was.
+ //
+ // Armed only for [stream], i.e. only while a route session is live,
+ // and torn down with the subscription when the workout ends. The blue
+ // background-location indicator stays ON for the whole session: if we
+ // are reading location with the app backgrounded, the user sees it.
+ allowBackgroundLocationUpdates: true,
+ showBackgroundLocationIndicator: true,
);
}
return const LocationSettings(
diff --git a/lib/gps/screen_wake.dart b/lib/gps/screen_wake.dart
new file mode 100644
index 00000000..a7b36b1e
--- /dev/null
+++ b/lib/gps/screen_wake.dart
@@ -0,0 +1,107 @@
+// ScreenWake — hold the display awake for the duration of a live workout.
+//
+// Every serious run/ride app does this: the athlete has the phone on a bar
+// mount or an armband and glances at it, they do not tap it every 30 s to stop
+// the screen sleeping. Before this, the live session screen carried a "Keep the
+// screen on to map your route" hint — asking the user to work around the app.
+//
+// Deliberately NOT a new dependency. Both platforms already have a registered
+// method channel, and the native primitive is one line each:
+// • Android — FLAG_KEEP_SCREEN_ON on the activity window (window-scoped, so
+// it is released automatically when the activity goes away).
+// • iOS — UIApplication.isIdleTimerDisabled.
+// Neither is a true CPU wakelock: they keep the DISPLAY on while the app is
+// frontmost and nothing more, so a leaked flag can never drain the battery in
+// the background. Background *recording* is a separate mechanism entirely (the
+// location background mode / FGS location type — see gps_source.dart).
+//
+// Failure is always silent: a screen that sleeps is a papercut, never a reason
+// to interrupt a workout.
+
+import 'dart:io';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+
+class ScreenWake {
+ static const _android = MethodChannel('openstrap/edge_tracking');
+ static const _ios = MethodChannel('openstrap/ios_config');
+
+ /// What the PLATFORM last confirmed, not what we last asked for.
+ ///
+ /// Only updated after a successful call. Android returns false when no
+ /// activity is attached; latching the requested value before the result came
+ /// back meant a failed enable left Dart believing the screen was held and
+ /// short-circuited every later retry.
+ static bool _on = false;
+
+ /// Test seam for the platform switch below.
+ ///
+ /// `Platform.isAndroid` and `Platform.isIOS` are BOTH false on the host VM
+ /// that widget tests run on, so without this the dispatch short-circuits and
+ /// no test can reach either MethodChannel — the mocks looked wired up and
+ /// asserted nothing. Set to 'android' or 'ios' in a test; null = real
+ /// platform.
+ @visibleForTesting
+ static String? platformOverride;
+
+ static bool get _isAndroid =>
+ platformOverride == null ? Platform.isAndroid : platformOverride == 'android';
+ static bool get _isIOS =>
+ platformOverride == null ? Platform.isIOS : platformOverride == 'ios';
+
+ @visibleForTesting
+ static bool get isHeld => _on;
+
+ /// Serializes transitions so each one sees the state the previous one left.
+ ///
+ /// Without this, `_on` is only updated AFTER the platform await, so a
+ /// `release()` arriving while an `enable()` is still in flight reads the
+ /// stale `false`, decides it has nothing to do, and returns — then the
+ /// in-flight enable latches `_on = true` and the display stays held for the
+ /// rest of the app's life. Both call sites are fire-and-forget from
+ /// AppState, so a short workout (start then immediately stop) is enough to
+ /// hit it.
+ static Future _chain = Future.value();
+
+ /// Keep the display awake. Safe to call repeatedly.
+ static Future enable() => _set(true);
+
+ /// Release the display. MUST be called when the session ends — including on
+ /// the error/abort paths, or the screen stays awake until the app is killed.
+ static Future release() => _set(false);
+
+ static Future _set(bool on) {
+ final next = _chain.then((_) => _apply(on));
+ // Keep the chain alive even if a link fails; _apply already swallows, this
+ // is belt-and-braces so one bad transition can't wedge every later one.
+ _chain = next.catchError((_) {});
+ return next;
+ }
+
+ static Future _apply(bool on) async {
+ if (on == _on) return;
+ try {
+ if (_isAndroid) {
+ final ok = await _android.invokeMethod('keepAwake', {'on': on});
+ // Android answers false when there is no attached activity. Leave the
+ // flag alone so the next call retries rather than assuming success.
+ if (ok != true) return;
+ } else if (_isIOS) {
+ await _ios.invokeMethod('keepAwake', {'on': on});
+ }
+ _on = on;
+ } catch (e) {
+ // Never surface: losing the wake flag degrades to "screen sleeps". The
+ // flag stays unchanged, so a later attempt can still succeed.
+ debugPrint('[screen-wake] ${on ? 'enable' : 'release'} failed: $e');
+ }
+ }
+
+ @visibleForTesting
+ static void resetForTest() {
+ _on = false;
+ platformOverride = null;
+ _chain = Future.value();
+ }
+}
diff --git a/lib/main.dart b/lib/main.dart
index bd57c11b..dccd2162 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -62,6 +62,18 @@ Future main() async {
// freezing isn't a crash. See installJankWatchdog's doc for the threshold.
TelemetryService.instance.installJankWatchdog();
+ // Cap the decoded-image cache. Flutter's default is 1000 entries / 100 MiB of
+ // DECODED bitmaps, which is sized for a photo feed, not for us. The only thing
+ // in this app that can fill it is map tiles: a retina 512² tile costs ~1 MiB
+ // decoded, so a long ride that pans continuously could sit on ~100 MiB of
+ // resident tile bitmaps — on top of the deliberately-persistent pre-warmed
+ // FlutterEngine — and push a 3–4 GB device into LMK/jetsam territory mid-
+ // workout. 40 MiB still covers several screens of tiles either side of the
+ // route; evicted tiles simply re-decode from the network layer.
+ PaintingBinding.instance.imageCache
+ ..maximumSizeBytes = 40 << 20
+ ..maximumSize = 200;
+
// Android: Cancel the two legacy WorkManager tasks by unique name. A previous
// version scheduled heavy derivation passes in the background, but they were
// pulled due to isolate collisions/deadlocks with the main UI's database access.
diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart
index 68e57f98..e83462e2 100644
--- a/lib/state/app_state.dart
+++ b/lib/state/app_state.dart
@@ -44,6 +44,7 @@ import '../data/live_coverage_policy.dart';
import '../data/local_repository.dart';
import '../gps/gps_source.dart';
import '../gps/route_tracker.dart';
+import '../gps/screen_wake.dart';
import '../data/local_repository_impl.dart';
import '../notify/notification_center.dart';
import '../notify/notification_event.dart';
@@ -748,8 +749,24 @@ class AppState extends ChangeNotifier {
if (route != null) _handleTapRoute(route);
}
+ /// Central disposal guard.
+ ///
+ /// Setting `_disposed` and checking it at each await point only covers the
+ /// paths someone remembered to guard. Several notifications reach here from
+ /// places that never see that flag — the derive scheduler's `onChanged`
+ /// callback, in-flight `_afterDrain()` continuations, BLE engine callbacks —
+ /// and notifying a disposed ChangeNotifier throws in release. Overriding the
+ /// single funnel every one of them goes through makes the guard total instead
+ /// of a list of remembered sites.
+ @override
+ void notifyListeners() {
+ if (_disposed) return;
+ super.notifyListeners();
+ }
+
@override
void dispose() {
+ _disposed = true;
// EVERY timer this object owns, not just three of them. _spotTimer,
// _breathingRecomputeTimer and _workoutTimer used to survive dispose, and
// each of their callbacks ends in notifyListeners() on a disposed
@@ -3203,6 +3220,15 @@ class AppState extends ChangeNotifier {
unawaited(engine.retryFullLiveStreams());
}
_workoutRawBase = _liveRaw;
+ // Hold heavy derivation for the session — an isolate spawn mid-ride
+ // competes with GPS, the live map and the BLE drain (see
+ // DeriveScheduler.setWorkoutActive).
+ _deriveScheduler.setWorkoutActive(true);
+ // 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();
activeWorkout = LiveWorkoutState(
startTime: start,
targetKcal: targetKcal,
@@ -3248,6 +3274,13 @@ class AppState extends ChangeNotifier {
/// off" affordance instead of silently skipping the map.
GpsPermissionStatus? routeLocationIssue;
+ /// Set in [dispose]. Async work that resumes AFTER teardown must not touch
+ /// state or call notifyListeners() — see dispose()'s own note about
+ /// notifying a disposed ChangeNotifier (which throws in release). Timers are
+ /// cancelled there, but an already-suspended `await` cannot be, so every
+ /// continuation past an await in this class needs to re-check this.
+ bool _disposed = false;
+
/// Start recording the route if the type is eligible and location permission
/// is granted. Denial is surfaced (routeLocationIssue) — the workout still
/// runs without a map, but the user is told why and how to fix it.
@@ -3261,6 +3294,9 @@ class AppState extends ChangeNotifier {
} catch (_) {
perm = GpsPermissionStatus.error;
}
+ // The permission round-trip can outlive the whole AppState (a resumed
+ // workout kicks this off unawaited during startup), so re-check both.
+ if (_disposed) return;
// The session may have ended while we awaited the permission dialog.
if (activeWorkout?.workoutId != id) return;
if (perm != GpsPermissionStatus.granted) {
@@ -3388,6 +3424,16 @@ class AppState extends ChangeNotifier {
(_) => _tickWorkout(),
);
_log('[workout] resumed a live session still running after restart (id=$id).');
+ // Re-arm GPS for the REST of the session. Without this a resumed
+ // workout recorded no further route at all: the timer/calories/strain
+ // all came back, the map silently never did, and the athlete only
+ // found out at the finish screen. `_maybeStartRouteTracking` is a
+ // no-op for non-route types and re-appends to the SAME workout_route
+ // rows (`id` is unchanged), so the pre-restart part of the route is
+ // kept and the gap shows honestly as a segment break.
+ unawaited(_maybeStartRouteTracking(id, activeWorkout!.type));
+ _deriveScheduler.setWorkoutActive(true);
+ ScreenWake.enable();
} else {
await LocalDb.putSession({...row, 'status': 'done'});
_log('[workout] finalized a stale live-session row from a previous run (id=${row['id']}).');
@@ -3416,6 +3462,12 @@ class AppState extends ChangeNotifier {
// Android: drop the FGS back to connectedDevice-only now the route ended.
EdgeTracking.start(location: false);
}
+ // Release the display unconditionally — not inside the `rt != null` branch.
+ // A session that never got a tracker (permission denied) still armed
+ // nothing, but a session whose tracker was already cleared by another path
+ // would otherwise leave the screen pinned awake until the app is killed.
+ ScreenWake.release();
+ _deriveScheduler.setWorkoutActive(false);
final w = activeWorkout!;
final finalKcal = w.calories.round();
final wSteps = workoutSteps; // real steps taken during this workout
@@ -3479,6 +3531,8 @@ class AppState extends ChangeNotifier {
} catch (_) {}
EdgeTracking.start(location: false);
}
+ ScreenWake.release();
+ _deriveScheduler.setWorkoutActive(false);
activeWorkout = null;
_workoutRawBase = null;
LiveActivity.end();
@@ -3649,6 +3703,15 @@ class LiveWorkoutState {
int currentHr = 0;
int maxHrSeen = 0; // spike-suppressed peak live HR this session (issue #127)
+ /// Milestone keys already announced this SESSION ("t5", "k200", "mhr178"…).
+ ///
+ /// Lives here, not on the live-session screen's State, because the screen is
+ /// disposed and rebuilt every time the athlete navigates away and back — so
+ /// a screen-local set forgot everything and re-fired the same milestone
+ /// (banner, haptic and confetti) on every return. The workout is the thing
+ /// a milestone belongs to, so the workout remembers it.
+ final Set firedMilestones = {};
+
/// Rolling-median accumulator behind [maxHrSeen] — smooths the live 1 Hz HR
/// at accrual so a transient PPG motion spike can't set the session max (or
/// fire a spurious "new max!"). Same window + reject as the on-read recompute.
diff --git a/lib/theme/tokens.dart b/lib/theme/tokens.dart
index acf107e5..f04b5532 100644
--- a/lib/theme/tokens.dart
+++ b/lib/theme/tokens.dart
@@ -195,7 +195,35 @@ class AppColors {
// device card, the live-workout screen, splash overlays). ──
static const night = Color(0xFF181613);
static const nightAlt = Color(0xFF24211D);
+
+ // ── Ink ramp for permanently-dark surfaces (the live session screen) ──
+ //
+ // These exist because that screen was written with ad-hoc `Colors.white30` /
+ // `white38` values, and MEASURED against [nightAlt] they do not clear the
+ // WCAG AA floor for small text (4.5:1):
+ //
+ // white24 → 2.20:1 white30 → 2.72:1 white38 → 3.49:1
+ //
+ // Its labels are 9 px overlines, so that is squarely small text — the
+ // "labels are invisible on the dark panel" report. Opacity is a convenient
+ // knob but it is not a contrast decision; picking one requires knowing the
+ // backdrop, which is exactly what a token can encode and a call site cannot.
+ //
+ // Ratios below are against [nightAlt] (the sheet); every one is higher
+ // against the darker [night]. Guarded by test/zone_contrast_test.dart.
+ /// Muted ink (overline labels, units) — 5.77:1. The FLOOR for small text on
+ /// this surface; do not reach for a lower opacity instead.
+ ///
+ /// [onNight] (14.23:1) and [onNightSoft] (6.21:1) below already existed and
+ /// already pass — the live session screen simply wasn't using them, and
+ /// reached for raw `Colors.whiteNN` instead. This adds the third step that
+ /// was missing so there is a token for every role and no reason to.
+ static const onNightMuted = Color(0xFF9C9B99);
+
+ /// Primary ink on a dark session surface — 14.23:1.
static const onNight = Color(0xFFF4F1EC);
+
+ /// Secondary ink (values, unselected controls) — 6.21:1.
static const onNightSoft = Color(0xFFA8A096);
// ── Accent — ember coral (mode-varying). Alert/urgent semantics ONLY —
@@ -253,23 +281,52 @@ class AppColors {
// ── HR zone palette (Z0..Z5) — the single source for zone colours. Reads the
// active palette at call time, so it re-themes for free. Both the live
// session ladder and the workouts zone bars source their colours here. ──
- static Color zone(int z) {
+ static Color zone(int z) => zoneIn(active, z);
+
+ /// The zone ramp resolved against a SPECIFIC palette rather than whatever is
+ /// active. Needed because not every surface follows the app theme.
+ static Color zoneIn(Palette p, int z) {
switch (z.clamp(0, 5)) {
case 0:
- return cool; // resting / below zone 1
+ return p.cool; // resting / below zone 1
case 1:
- return loadDetraining; // warm-up
+ return p.loadDetraining; // warm-up
case 2:
- return good; // fat burn
+ return p.good; // fat burn
case 3:
- return warn; // aerobic
+ return p.warn; // aerobic
case 4:
- return coral; // threshold
+ return p.coral; // threshold
default:
- return coralDeep; // max effort (Z5)
+ return p.coralDeep; // max effort (Z5)
}
}
+ /// Zone colour for a surface that is ALWAYS dark, regardless of the user's
+ /// theme — today that means the live workout session screen, which paints on
+ /// [night]/[nightAlt] whether the app is in light or dark mode.
+ ///
+ /// Two separate legibility bugs are fixed here, both measured rather than
+ /// eyeballed (WCAG relative-luminance contrast against [nightAlt]):
+ ///
+ /// 1. Plain [zone] resolves the ACTIVE palette. With the app in LIGHT mode
+ /// that returned hues tuned for contrast against white and painted them
+ /// on near-black.
+ /// 2. Even on the dark palette, Z0 mapped to `cool` — which is a SURFACE
+ /// token (a dark cool-grey panel), not an ink. As a foreground it
+ /// measured **1.03:1** against nightAlt: literally invisible. And Z0 is
+ /// the resting zone, i.e. exactly what is on screen at the start of
+ /// every workout and whenever heart rate is low or absent.
+ ///
+ /// Z0 therefore uses `coolInk` — the token that already exists as "ink on
+ /// the cool surface" — measuring 9.76:1. The rest of the ramp was already
+ /// clear (5.7:1 – 8.9:1) and is unchanged.
+ ///
+ /// Guarded by a test that asserts every zone clears 3:1 on this surface, so
+ /// a future palette edit cannot silently reintroduce an invisible zone.
+ static Color zoneOnDark(int z) =>
+ z.clamp(0, 5) == 0 ? kDarkPalette.coolInk : zoneIn(kDarkPalette, z);
+
/// A soft tint of a zone colour — for faint backfills / legend swatches.
static Color zoneSoft(int z) => zone(z).withValues(alpha: 0.16);
diff --git a/lib/ui/activity/live_session_screen.dart b/lib/ui/activity/live_session_screen.dart
index 396fe28c..0aea85b7 100644
--- a/lib/ui/activity/live_session_screen.dart
+++ b/lib/ui/activity/live_session_screen.dart
@@ -4,16 +4,10 @@
// an "in the red" streak, milestone bursts, and a playful line engine. Code-drawn
// (CustomPaint), haptics-only, open-ended. Long-press to finish → breakdown.
-import 'dart:io';
import 'dart:math' as math;
-import 'dart:ui';
-import 'dart:ui' as ui;
import 'package:flutter/material.dart';
-import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
-import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
-import 'package:share_plus/share_plus.dart';
import '../../models/payloads.dart';
import '../../state/app_state.dart';
@@ -21,6 +15,7 @@ import '../../state/units_controller.dart';
import '../../theme/theme.dart';
import '../../theme/theme_switcher.dart';
import '../../theme/tokens.dart';
+import 'workout_share_card.dart';
import '../kit/kit.dart';
import '../kit/charts.dart';
import '../kit/route_map.dart';
@@ -52,14 +47,41 @@ class _ZoneMeta {
const _ZoneMeta(this.label, this.name, this.color);
}
-final List<_ZoneMeta> _zones = [
- _ZoneMeta('Z0', 'Resting', AppColors.zone(0)),
- _ZoneMeta('Z1', 'Warm-up', AppColors.zone(1)),
- _ZoneMeta('Z2', 'Fat burn', AppColors.zone(2)),
- _ZoneMeta('Z3', 'Aerobic', AppColors.zone(3)),
- _ZoneMeta('Z4', 'Threshold', AppColors.zone(4)),
- _ZoneMeta('Z5', 'Max effort', AppColors.zone(5)),
+/// Zone labels. Colours are NOT baked in here — see [_zones].
+const List<(String, String)> _zoneNames = [
+ ('Z0', 'Resting'),
+ ('Z1', 'Warm-up'),
+ ('Z2', 'Fat burn'),
+ ('Z3', 'Aerobic'),
+ ('Z4', 'Threshold'),
+ ('Z5', 'Max effort'),
];
+
+/// Zone metadata for the live session screen.
+///
+/// TWO bugs lived in the old `final List<_ZoneMeta> _zones = [...]` here:
+///
+/// 1. It resolved `AppColors.zone(z)` from the ACTIVE palette, but this
+/// screen always paints on [AppColors.night] regardless of the app theme.
+/// In light mode that handed back hues tuned for a white background and
+/// painted them on near-black — the low zones were effectively invisible.
+/// 2. Being a top-level `final`, it was initialised ONCE at first access and
+/// then never re-themed, so even switching themes could not fix it.
+///
+/// Now it is a function over the dark ramp, evaluated per build.
+_ZoneMeta _zoneAt(int z) {
+ final i = z.clamp(0, 5);
+ return _ZoneMeta(
+ _zoneNames[i].$1, _zoneNames[i].$2, AppColors.zoneOnDark(i));
+}
+
+/// Indexable shim so existing `_zones[z]` call sites keep reading naturally.
+class _ZoneTable {
+ const _ZoneTable();
+ _ZoneMeta operator [](int z) => _zoneAt(z);
+}
+
+const _zones = _ZoneTable();
const _zonePct = [0.0, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]; // lower bound of z0..z5 then top
// Playful + a little funny lines, by zone bucket.
@@ -88,7 +110,6 @@ class _LiveSessionScreenState extends State
int _lastZone = -1;
DateTime? _redStart; // start of continuous time in zone ≥3
Duration _redStreak = Duration.zero;
- final Set _milestones = {};
String _line = '';
int _lineSeed = 0;
String? _callout; // ephemeral big banner ("ZONE 4")
@@ -120,7 +141,30 @@ class _LiveSessionScreenState extends State
void _onRoutePathTick() {
if (_userToggledMap || !mounted) return;
final hasRoute = _observedTracker?.path.value.isNotEmpty ?? false;
- if (hasRoute != _showMap) setState(() => _showMap = hasRoute);
+ if (hasRoute != _showMap) {
+ setState(() => _showMap = hasRoute);
+ _syncDecorativeAnimations();
+ }
+ }
+
+ /// Park the purely decorative animations while the map is the primary view.
+ ///
+ /// `_beat` (HR pulse) and `_fx` (ember field) are `repeat()`-forever
+ /// controllers. Their painters are already gated behind `if (!mapOn)`, but a
+ /// running Ticker keeps requesting frames regardless — so a 90-minute ride
+ /// spent entirely on the map view still drove a 60 fps vsync loop the whole
+ /// time, burning battery and generating heat for pixels nobody was drawing.
+ /// Stopping the controllers stops the frame requests; they resume the moment
+ /// the athlete flips back to the ring view.
+ 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
@@ -221,9 +265,15 @@ class _LiveSessionScreenState extends State
_calloutUntil = DateTime.now().add(const Duration(seconds: 3));
}
+ /// Announce a milestone ONCE per session.
+ ///
+ /// The dedup set lives on the workout, not on this State: leaving the screen
+ /// and coming back disposes and rebuilds this widget, which used to reset a
+ /// screen-local set and re-fire "5 MINUTES" (banner + haptic + confetti)
+ /// every single time the athlete returned to the live screen.
void _milestone(String key, String big, String sub, Color c) {
- if (_milestones.contains(key)) return;
- _milestones.add(key);
+ final fired = _app?.activeWorkout?.firedMilestones;
+ if (fired == null || !fired.add(key)) return;
_fireCallout(big, sub);
_fireConfetti(c);
HapticFeedback.mediumImpact();
@@ -298,280 +348,196 @@ class _LiveSessionScreenState extends State
final gapBpm = zone < 5 ? (_zonePct[zone + 1] * _maxHr).ceil() - hr : 0;
final almost = zone < 5 && hr > 0 && gapBpm > 0 && gapBpm <= 5;
final calloutOn = _callout != null && DateTime.now().isBefore(_calloutUntil);
-
- // GPS map mode is now a dedicated layout, not a small box floating over
- // the ember/HR-reactive core: that layering (separate zone ladder, big
- // timer, and HR circle all still rendering underneath a boxed map) is
- // exactly what read as badly composed. When a route exists, the map IS
- // the screen — BPM/zone/duration move into its own unified stat bar
- // (GpsLiveMapView) instead of competing with a second UI system. The
- // ember core stays untouched for non-GPS workouts, where it's the right
- // default.
final mapOn = _showMap && app.routeTracker != null;
+ // The map/heart view switch lives in the SHEET, next to the stats — not
+ // floating over the map. On the map it sat in the top-right corner, which
+ // is both the least reachable part of a phone one-handed mid-run and prime
+ // map real estate. Down here it reads as what it is: a control for what
+ // the panel above is showing.
+ final viewToggle = app.routeTracker == null
+ ? null
+ : _ViewToggle(
+ showingMap: _showMap,
+ onChanged: (wantMap) {
+ if (wantMap == _showMap) return;
+ setState(() {
+ _showMap = wantMap;
+ _userToggledMap = true;
+ });
+ _syncDecorativeAnimations();
+ },
+ );
+
+ // ── LAYOUT CONTRACT ──────────────────────────────────────────────────
+ // Two regions in a Column: a bounded HERO (map or heart-rate core) and a
+ // METRIC SHEET. They are siblings, so the sheet can never sit on top of
+ // the hero and the hero can never grow under the sheet.
+ //
+ // This screen used to be one flat Stack of absolutely-positioned layers
+ // with no layout relationship between them, and they collided on real
+ // devices: the map's re-centre button was pinned `bottom: 96` while the
+ // control panel is far taller than that, so it rendered UNDERNEATH the
+ // panel; the centred recording pill ran under the 44 px map toggle; and
+ // in ring mode the fixed 270 px core had nothing stopping it colliding
+ // with the timer above and the panel below on a shorter phone.
+ //
+ // Anything that genuinely floats (re-centre, callout, confetti) is now
+ // Positioned INSIDE the hero's own Stack, so it is clipped to the hero
+ // and anchored to the hero's edges — never the screen's.
return Theme(
data: ThemeData.dark().copyWith(scaffoldBackgroundColor: AppColors.night),
child: Scaffold(
- body: Stack(children: [
- // 1. Zone-tinted studio background, intensity climbs with effort.
- if (!mapOn)
- Positioned.fill(child: AnimatedContainer(
- duration: Motion.slow,
- decoration: BoxDecoration(gradient: RadialGradient(
- center: const Alignment(0, -0.15), radius: 1.4,
- colors: [z.color.withValues(alpha: 0.12 + 0.30 * hrrPct), AppColors.night],
- )),
- )),
-
- // 2. Ember field rising behind the core (count/heat ∝ effort).
- if (!mapOn)
- Positioned.fill(child: AnimatedBuilder(
- animation: _fx,
- builder: (context, _) => CustomPaint(painter: _EmberPainter(t: _fx.value, intensity: hrrPct, color: z.color)),
- )),
-
- // 3. Top: the big tabular timer (the refs' huge session clock) +
- // in-the-red streak. Weight and space, no chrome. (Map mode shows
- // duration in its own unified stat bar instead — see 5b.)
- if (!mapOn)
- SafeArea(child: Padding(
- padding: const EdgeInsets.symmetric(vertical: Sp.x4),
- child: Column(children: [
- Text(
- _fmt(w.elapsed),
- style: AppText.hero.copyWith(
- fontSize: 40,
- color: Colors.white,
- letterSpacing: 0,
- ),
- ),
- Text(
- 'DURATION',
- style: AppText.overline.copyWith(
- color: Colors.white30,
- fontSize: 9,
- letterSpacing: 3,
+ body: Column(
+ children: [
+ Expanded(
+ child: Stack(
+ children: [
+ if (mapOn)
+ Positioned.fill(
+ child: _LiveRouteMap(
+ tracker: app.routeTracker!,
+ elapsed: w.elapsed,
+ hr: hr,
+ zoneIndex: zone,
+ showStatBar: false,
+ ),
+ )
+ else
+ Positioned.fill(
+ child: _HeroCore(
+ hr: hr,
+ zone: zone,
+ hrrPct: hrrPct,
+ elapsed: w.elapsed,
+ redStreak: _redStreak,
+ line: _line,
+ almostText: almost
+ ? '$gapBpm bpm to ${_zones[zone + 1].label} — push'
+ : null,
+ almostColor:
+ zone < 5 ? _zones[zone + 1].color : z.color,
+ beat: _beat,
+ fx: _fx,
+ fmt: _fmt,
+ ),
+ ),
+
+ // Top rail — ONE row, space-between. The state chip and the
+ // map toggle are laid out against each other, so no amount
+ // of text can push one under the other (the chip is
+ // Flexible and ellipsizes instead).
+ Positioned(
+ top: 0,
+ left: 0,
+ right: 0,
+ child: SafeArea(
+ bottom: false,
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(
+ Sp.x5, Sp.x3, Sp.x5, 0),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Flexible(
+ child: _SessionStateChip(
+ locationIssue: app.routeTracker == null
+ ? app.routeLocationIssue
+ : null,
+ onFixLocation: () async {
+ final issue = app.routeLocationIssue;
+ if (issue == null) return;
+ if (issue == GpsPermissionStatus.denied) {
+ await app.retryRouteTracking();
+ } else {
+ await GpsSource.openSettingsFor(issue);
+ }
+ },
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
),
- ),
- if (_redStreak.inSeconds >= 5) ...[
- const SizedBox(height: Sp.x2),
- _pill(AppIcon(OsIcon.calories, size: 14, color: AppColors.coral),
- '${_fmt(_redStreak)} in the red', tint: AppColors.coral),
- ],
- ]),
- )),
-
- // 4. The ember core (beats at your HR). Map mode shows BPM + zone
- // in its own unified stat bar instead — see 5b.
- if (!mapOn)
- Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
- Stack(alignment: Alignment.center, children: [
- SizedBox(width: 270, height: 270, child: CustomPaint(
- painter: _ZoneArcPainter(pct: hrrPct, color: z.color))),
- AnimatedBuilder(
- animation: _beat,
- builder: (context, child) {
- final v = (hr > 160 ? Curves.elasticOut : Curves.easeInOut).transform(_beat.value);
- final scale = 1.0 + 0.08 * v;
- final glow = 0.4 + 0.6 * v;
- return Container(
- width: 210, height: 210,
- decoration: BoxDecoration(shape: BoxShape.circle, boxShadow: [
- BoxShadow(color: z.color.withValues(alpha: 0.4 * glow), blurRadius: 40 * scale, spreadRadius: 2),
- BoxShadow(color: z.color.withValues(alpha: 0.15 * glow), blurRadius: 100 * scale, spreadRadius: 10),
- ]),
- child: Transform.scale(scale: scale, child: Container(
- decoration: BoxDecoration(shape: BoxShape.circle, color: AppColors.night,
- border: Border.all(color: z.color.withValues(alpha: 0.35), width: 1.5)),
- alignment: Alignment.center, child: child,
- )),
- );
- },
- child: Column(mainAxisSize: MainAxisSize.min, children: [
- Text(hr > 0 ? '$hr' : '—', style: AppText.display.copyWith(
- fontSize: 88, color: Colors.white, height: 1, fontWeight: FontWeight.w900)),
- Text('BPM', style: AppText.overline.copyWith(
- color: Colors.white38, fontSize: 11, letterSpacing: 5, fontWeight: FontWeight.w800)),
- ]),
- ),
- ]),
- const SizedBox(height: Sp.x8),
- AnimatedDefaultTextStyle(
- duration: Motion.med,
- style: AppText.h2.copyWith(color: z.color, letterSpacing: 3, fontWeight: FontWeight.w900, fontSize: 22),
- child: Text('${z.label} · ${z.name}'.toUpperCase()),
- ),
- const SizedBox(height: Sp.x2),
- // "Almost there" nudge or the playful line.
- SizedBox(height: 22, child: AnimatedSwitcher(
- duration: Motion.med,
- child: almost
- ? Text('$gapBpm bpm to ${_zones[zone + 1].label} — push',
- key: ValueKey('almost$gapBpm'),
- style: AppText.bodySoft.copyWith(color: _zones[zone + 1].color, fontWeight: FontWeight.w700))
- : Text(_line, key: ValueKey(_line),
- style: AppText.bodySoft.copyWith(color: Colors.white38)),
- )),
- ])),
-
- // 5. Zone ladder (right edge). Redundant with map mode's own
- // BPM/zone stat — suppressed there.
- if (!mapOn)
- Positioned(right: Sp.x4, top: 0, bottom: 0, child: Center(child: _zoneLadder(zone))),
-
- // 5b. Live route map — for a GPS workout (run/ride/walk) this IS
- // the screen now, full-bleed, not a small box over the ember core.
- // showStatBar: false — the merged _GpsControlPanel below shows
- // these same live stats in ONE glass card instead of a second,
- // competing bar stacked on the map.
- if (mapOn)
- Positioned.fill(
- child: _LiveRouteMap(
- tracker: app.routeTracker!,
- elapsed: w.elapsed,
- hr: hr,
- zoneIndex: zone,
- showStatBar: false,
- ),
- ),
- // 6. Stat panel + hold-to-finish. ONE glass card either way now —
- // in map mode it also carries the live distance/duration/pace/BPM
- // readout (via _GpsControlPanel), instead of a second stat bar
- // floating separately on the map.
- // Bottom offset adds the system gesture-nav inset — on Android the
- // fixed Sp.x8 alone let the hold-to-finish control sit under/behind
- // the nav bar on devices with a gesture bar.
- Positioned(left: Sp.x6, right: Sp.x6,
- bottom: Sp.x8 + MediaQuery.of(context).padding.bottom,
- child: mapOn
- ? _GpsControlPanel(
- tracker: app.routeTracker!,
- elapsed: w.elapsed,
- hr: hr,
- zoneIndex: zone,
- workout: w,
- holdController: _hold,
- ending: _ending,
- onFinished: _finish,
- )
- : _ControlPanel(workout: w, holdController: _hold, ending: _ending, onFinished: _finish)),
-
- // 7. Celebration confetti (one-shot).
- Positioned.fill(child: IgnorePointer(child: AnimatedBuilder(
- animation: _burst,
- builder: (context, _) => _burst.isAnimating
- ? CustomPaint(painter: _ConfettiPainter(t: _burst.value, particles: _confetti))
- : const SizedBox.shrink(),
- ))),
-
- // 8. Big ephemeral callout (zone-up / milestone).
- if (calloutOn) Positioned.fill(child: IgnorePointer(child: Center(
- child: Column(mainAxisSize: MainAxisSize.min, children: [
- const Spacer(flex: 2),
- Text(_callout!, style: AppText.display.copyWith(
- fontSize: 46, color: Colors.white, fontWeight: FontWeight.w900, letterSpacing: 2)),
- if (_calloutSub != null)
- Text(_calloutSub!, style: AppText.label.copyWith(color: z.color, letterSpacing: 3)),
- const Spacer(flex: 3),
- ]),
- ))),
-
- // 9b. Location denied/off for a route-eligible workout → say so and
- // offer the fix, instead of silently running without a map.
- if (app.routeTracker == null && app.routeLocationIssue != null)
- Positioned(
- top: MediaQuery.of(context).padding.top + 64,
- left: Sp.x5,
- right: Sp.x5,
- child: GestureDetector(
- behavior: HitTestBehavior.opaque,
- onTap: () async {
- final issue = app.routeLocationIssue!;
- if (issue == GpsPermissionStatus.denied) {
- // Re-prompt is still possible — retry in place.
- await app.retryRouteTracking();
- } else {
- await GpsSource.openSettingsFor(issue);
- }
- },
- child: Center(
- child: _pill(
- const Icon(Icons.location_off_outlined,
- size: 15, color: Colors.white60),
- app.routeLocationIssue == GpsPermissionStatus.serviceOff
- ? 'Location off — turn it on to map your route'
- : 'Location off — allow it to map your route',
- tint: AppColors.warn,
+ // Ephemeral zone-up / milestone callout.
+ if (calloutOn)
+ Positioned.fill(
+ child: IgnorePointer(
+ child: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ _callout!,
+ textAlign: TextAlign.center,
+ style: AppText.display.copyWith(
+ fontSize: 46,
+ color: Colors.white,
+ fontWeight: FontWeight.w900,
+ letterSpacing: 2,
+ ),
+ ),
+ if (_calloutSub != null)
+ Text(
+ _calloutSub!,
+ textAlign: TextAlign.center,
+ style: AppText.label.copyWith(
+ color: z.color, letterSpacing: 3),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+
+ // Confetti stays clipped to the hero.
+ Positioned.fill(
+ child: IgnorePointer(
+ child: AnimatedBuilder(
+ animation: _burst,
+ builder: (context, _) => _burst.isAnimating
+ ? CustomPaint(
+ painter: _ConfettiPainter(
+ t: _burst.value, particles: _confetti))
+ : const SizedBox.shrink(),
+ ),
+ ),
),
- ),
+ ],
),
),
- // 9. Map-mode toggle (run/ride/walk with a live route only).
- if (app.routeTracker != null)
- Positioned(
- top: MediaQuery.of(context).padding.top + Sp.x5,
- right: Sp.x5,
- child: GestureDetector(
- onTap: () => setState(() {
- _showMap = !_showMap;
- _userToggledMap = true; // respect the explicit choice now
- }),
- behavior: HitTestBehavior.opaque,
- child: Container(
- width: 44,
- height: 44,
- decoration: BoxDecoration(
- shape: BoxShape.circle,
- color: _showMap
- ? AppColors.coral.withValues(alpha: 0.9)
- : Colors.white.withValues(alpha: 0.10),
- border: Border.all(color: Colors.white24),
- ),
- child: Icon(
- _showMap ? Icons.favorite : Icons.map_outlined,
- size: 20,
- color: Colors.white,
+ // The metric sheet. Owns the bottom safe area itself.
+ mapOn
+ ? _GpsControlPanel(
+ tracker: app.routeTracker!,
+ elapsed: w.elapsed,
+ hr: hr,
+ zoneIndex: zone,
+ workout: w,
+ holdController: _hold,
+ ending: _ending,
+ onFinished: _finish,
+ viewToggle: viewToggle,
+ )
+ : _SessionSheet(
+ workout: w,
+ holdController: _hold,
+ ending: _ending,
+ onFinished: _finish,
+ hr: hr,
+ zoneIndex: zone,
+ elapsed: w.elapsed,
+ viewToggle: viewToggle,
),
- ),
- ),
- ),
- ]),
+ ],
+ ),
),
);
}
- Widget _pill(Widget icon, String text, {Color? tint}) => Container(
- padding: const EdgeInsets.symmetric(horizontal: Sp.x4, vertical: Sp.x2),
- decoration: BoxDecoration(
- color: (tint ?? Colors.white).withValues(alpha: 0.08),
- borderRadius: BorderRadius.circular(R.pill),
- border: Border.all(color: (tint ?? Colors.white).withValues(alpha: 0.18)),
- ),
- child: Row(mainAxisSize: MainAxisSize.min, children: [
- icon, const SizedBox(width: Sp.x2),
- Text(text, style: AppText.metricSm.copyWith(
- color: tint ?? Colors.white, fontSize: 15, letterSpacing: 0.5,
- fontFeatures: [const FontFeature.tabularFigures()])),
- ]),
- );
-
- Widget _zoneLadder(int zone) => Column(mainAxisSize: MainAxisSize.min, children: [
- for (int z = 5; z >= 1; z--) ...[
- AnimatedContainer(
- duration: Motion.med,
- width: z == zone ? 16 : 10,
- height: 30,
- decoration: BoxDecoration(
- color: z <= zone ? _zones[z].color.withValues(alpha: z == zone ? 1 : 0.5) : Colors.white12,
- borderRadius: BorderRadius.circular(6),
- boxShadow: z == zone ? [BoxShadow(color: _zones[z].color.withValues(alpha: 0.6), blurRadius: 12)] : null,
- ),
- ),
- if (z > 1) const SizedBox(height: 6),
- ],
- ]);
}
// ── Ember particle field ──────────────────────────────────────────────────────
@@ -653,142 +619,9 @@ class _ConfettiPainter extends CustomPainter {
}
// ── Stat panel + hold-to-finish (kept from the original, lightly adapted) ─────
-class _ControlPanel extends StatelessWidget {
- final LiveWorkoutState workout;
- final AnimationController holdController;
- final bool ending;
- final VoidCallback onFinished;
- // GPS-mode live stats — ONE glass card with the map's live readout on top
- // and the existing calories/strain/steps below, instead of two separate
- // floating panels stacked on the map (that's what read as "bolted on").
- // Null (via [gpsDistance]) when this isn't a GPS-tagged workout.
- final String? gpsDistance;
- final String? gpsDuration;
- final String? gpsPace;
- final int? gpsHr;
- final Color? gpsZoneColor;
- final String? gpsZoneLabel;
- const _ControlPanel({
- required this.workout,
- required this.holdController,
- required this.ending,
- required this.onFinished,
- this.gpsDistance,
- this.gpsDuration,
- this.gpsPace,
- this.gpsHr,
- this.gpsZoneColor,
- this.gpsZoneLabel,
- });
-
- bool get _hasGpsStats => gpsDistance != null;
-
- @override
- Widget build(BuildContext context) {
- return Column(mainAxisSize: MainAxisSize.min, children: [
- ClipRRect(
- borderRadius: BorderRadius.circular(R.card),
- child: BackdropFilter(
- filter: ImageFilter.blur(sigmaX: 25, sigmaY: 25),
- child: Container(
- padding: const EdgeInsets.all(Sp.x6),
- decoration: BoxDecoration(
- color: Colors.white.withValues(alpha: 0.05),
- borderRadius: BorderRadius.circular(R.card),
- border: Border.all(color: Colors.white10),
- ),
- child: Column(children: [
- if (_hasGpsStats) ...[
- // 2x2 grid, not 4-across — four full stats (icon+value+unit+
- // label each) in one row left ~70px per stat on a real phone
- // width, which crowded/crammed together. Two rows of two
- // gives each stat roughly double the room.
- Row(children: [
- Expanded(child: _Stat(icon: OsIcon.activity, label: 'DISTANCE', value: gpsDistance!, unit: '')),
- const SizedBox(width: Sp.x4),
- Expanded(child: _Stat(icon: OsIcon.activity, label: 'DURATION', value: gpsDuration!, unit: '')),
- ]),
- const SizedBox(height: Sp.x4),
- Row(children: [
- Expanded(child: _Stat(icon: OsIcon.activity, label: 'PACE', value: gpsPace!, unit: '')),
- const SizedBox(width: Sp.x4),
- // BPM stays white like the other three stats — zone colour
- // on the number itself read as a bug ("why is heart rate
- // blue?"), not a signal. The zone name in the label below
- // it already conveys the zone.
- Expanded(child: _Stat(
- icon: OsIcon.heartRate,
- label: gpsZoneLabel ?? '',
- value: (gpsHr ?? 0) > 0 ? '$gpsHr' : '—',
- unit: '',
- )),
- ]),
- const SizedBox(height: Sp.x4),
- const Divider(color: Colors.white10, height: 1),
- const SizedBox(height: Sp.x4),
- ],
- Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
- _Stat(icon: OsIcon.activity, label: 'CALORIES', value: workout.calories.round().toString(), unit: 'kcal'),
- _Stat(icon: OsIcon.activity, label: 'STRAIN', value: workout.strain.toStringAsFixed(1), unit: ''),
- // 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',
- // select, not watch — this was subscribing the whole row to
- // every AppState notifyListeners(), not just workoutSteps.
- value: context.select((a) => a.workoutSteps).toString(),
- unit: ''),
- ]),
- ]),
- ),
- ),
- ),
- const SizedBox(height: Sp.x5),
- GestureDetector(
- onLongPressStart: (_) { holdController.forward(); HapticFeedback.lightImpact(); },
- onLongPressEnd: (_) {
- if (holdController.value >= 1.0) { onFinished(); } else { holdController.reverse(); }
- },
- child: AnimatedBuilder(
- animation: holdController,
- builder: (context, child) {
- final val = holdController.value;
- return Transform.scale(
- scale: 1.0 - 0.05 * val,
- child: Container(
- width: double.infinity, height: 72,
- decoration: BoxDecoration(
- color: val > 0 ? Colors.white.withValues(alpha: 0.1) : AppColors.nightAlt,
- borderRadius: BorderRadius.circular(R.pill),
- border: Border.all(color: Color.lerp(Colors.white10, AppColors.coral, val)!, width: 1.5),
- ),
- child: Stack(alignment: Alignment.center, children: [
- Positioned.fill(child: FractionallySizedBox(
- alignment: Alignment.centerLeft, widthFactor: val,
- child: Container(decoration: BoxDecoration(
- color: AppColors.coral.withValues(alpha: 0.2 + 0.2 * val),
- borderRadius: BorderRadius.circular(R.pill))),
- )),
- Row(mainAxisAlignment: MainAxisAlignment.center, children: [
- AppIcon(OsIcon.cancel, size: 20, color: Color.lerp(Colors.white24, Colors.white, val)),
- const SizedBox(width: Sp.x3),
- Text(ending ? 'FINISHING…' : 'HOLD TO FINISH', style: AppText.label.copyWith(
- color: Color.lerp(Colors.white38, Colors.white, val),
- fontWeight: FontWeight.w900, letterSpacing: 3, fontSize: 13)),
- ]),
- ]),
- ),
- );
- },
- ),
- ),
- ]);
- }
-}
-
-/// Feeds the RouteTracker's live distance/speed into [_ControlPanel]'s merged
-/// GPS stat row — so it updates live without wrapping the whole ember-core
-/// Stack (confetti, callouts, etc.) in ValueListenableBuilders it doesn't
-/// need.
+/// Feeds the RouteTracker's live distance/speed into [_SessionSheet] without
+/// wrapping the whole hero (map, callouts, confetti) in ValueListenableBuilders
+/// it does not need.
class _GpsControlPanel extends StatelessWidget {
final RouteTracker tracker;
final Duration elapsed;
@@ -798,6 +631,7 @@ class _GpsControlPanel extends StatelessWidget {
final AnimationController holdController;
final bool ending;
final VoidCallback onFinished;
+ final Widget? viewToggle;
const _GpsControlPanel({
required this.tracker,
required this.elapsed,
@@ -807,43 +641,45 @@ class _GpsControlPanel extends StatelessWidget {
required this.holdController,
required this.ending,
required this.onFinished,
+ this.viewToggle,
});
- static const _zoneLabels = ['Rest', 'Warm', 'Fat', 'Aero', 'Thr', 'Max'];
-
- String _fmtDuration(Duration d) {
- final h = d.inHours;
- final m = (d.inMinutes % 60).toString().padLeft(2, '0');
- final s = (d.inSeconds % 60).toString().padLeft(2, '0');
- return h > 0 ? '$h:$m:$s' : '$m:$s';
- }
-
@override
Widget build(BuildContext context) {
final units = context.watch();
- final zone = zoneIndex.clamp(0, 5);
return ValueListenableBuilder(
valueListenable: tracker.distanceMeters,
builder: (context, meters, _) => ValueListenableBuilder(
valueListenable: tracker.currentSpeedMps,
builder: (context, speedMps, _) {
final movingSec = tracker.movingSeconds;
- final avgPace = units.pace(
- meters,
- movingSec > 0 ? movingSec : elapsed.inSeconds,
- );
+ // MOVING pace, never elapsed pace.
+ //
+ // This used to fall back to `elapsed` whenever movingSec was 0, which
+ // produced the "40:32 /km even though I barely moved" reading: a
+ // couple of hundred metres divided by every second the athlete had
+ // also spent standing still is not a pace, it is an average of
+ // walking and waiting. Every serious run/ride app reports pace over
+ // moving time for exactly this reason. With no moving time yet,
+ // `units.pace` returns "—", which is the honest answer.
+ final avgPace = units.pace(meters, movingSec);
final livePace = units.paceFromSpeed(speedMps);
- return _ControlPanel(
+ // `units.distance` returns e.g. "2.41 km" — split it so the sheet can
+ // set the figure and its unit at different weights.
+ final distanceText = units.distance(meters);
+ final parts = distanceText.split(' ');
+ return _SessionSheet(
workout: workout,
holdController: holdController,
ending: ending,
onFinished: onFinished,
- gpsDistance: units.distance(meters),
- gpsDuration: _fmtDuration(elapsed),
- gpsPace: livePace == '—' ? avgPace : livePace,
- gpsHr: hr,
- gpsZoneColor: AppColors.zone(zone),
- gpsZoneLabel: _zoneLabels[zone],
+ hr: hr,
+ zoneIndex: zoneIndex,
+ elapsed: elapsed,
+ distance: parts.first,
+ distanceUnit: parts.length > 1 ? parts.sublist(1).join(' ') : '',
+ pace: livePace == '—' ? avgPace : livePace,
+ viewToggle: viewToggle,
);
},
),
@@ -851,48 +687,6 @@ class _GpsControlPanel extends StatelessWidget {
}
}
-class _Stat extends StatelessWidget {
- final String label, value, unit;
- final OsIcon icon;
- const _Stat({
- required this.label,
- required this.value,
- required this.unit,
- required this.icon,
- });
- @override
- Widget build(BuildContext context) {
- return Column(mainAxisSize: MainAxisSize.min, children: [
- AppIcon(icon, size: 16, color: Colors.white38),
- const SizedBox(height: Sp.x2),
- // mainAxisSize.min + explicit centering: when this _Stat sits inside
- // an Expanded (the merged GPS stat row), a bare default Row here
- // fills the WIDER Expanded box and left-aligns within it — the icon
- // and label above/below stay centered (plain leaf widgets), so the
- // value+unit alone reads as shifted left relative to them. Shrink-
- // wrapping fixes that mismatch.
- Row(
- mainAxisSize: MainAxisSize.min,
- mainAxisAlignment: MainAxisAlignment.center,
- crossAxisAlignment: CrossAxisAlignment.baseline,
- textBaseline: TextBaseline.alphabetic,
- children: [
- Text(value, style: AppText.metric.copyWith(color: Colors.white, fontSize: 24)),
- if (unit.isNotEmpty) ...[const SizedBox(width: 4), Text(unit, style: AppText.caption.copyWith(color: Colors.white38))],
- ],
- ),
- const SizedBox(height: 4),
- Text(
- label,
- style: AppText.overline.copyWith(color: Colors.white30, fontSize: 9, letterSpacing: 1),
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- textAlign: TextAlign.center,
- ),
- ]);
- }
-}
-
// ═══════════════════════════════════════════════════════════════════════════
// F2 — cinematic post-workout finish card, on the design system.
//
@@ -956,7 +750,6 @@ class _WorkoutFinishScreenState extends State
);
final _rand = math.Random();
final List<_Particle> _particles = [];
- final GlobalKey _cardKey = GlobalKey();
Map? _detail;
List? _routeVertices;
@@ -965,7 +758,6 @@ class _WorkoutFinishScreenState extends State
bool _prWorkout = false;
bool _prSteps = false;
bool _confettiFired = false;
- bool _sharing = false;
@override
void initState() {
@@ -1082,6 +874,26 @@ class _WorkoutFinishScreenState extends State
double _seg(double a, double b) =>
Interval(a, b, curve: Curves.easeOutCubic).transform(_reveal.value);
+ /// Stage a section into the reveal: fades and lifts [child] over the
+ /// [from]..[to] slice of the timeline. The child is built ONCE and handed to
+ /// AnimatedBuilder as its `child`, so per-frame work is a single Opacity +
+ /// Transform — never a subtree rebuild. Anything whose CONTENT counts up with
+ /// the animation (the hero numbers) uses its own builder instead.
+ Widget _reveals(double from, double to, Widget child) => AnimatedBuilder(
+ animation: _reveal,
+ child: child,
+ builder: (context, built) {
+ final p = _seg(from, to);
+ return Opacity(
+ opacity: p,
+ child: Transform.translate(
+ offset: Offset(0, 14 * (1 - p)),
+ child: built,
+ ),
+ );
+ },
+ );
+
String _dur(Duration d) {
final h = d.inHours;
final m = d.inMinutes % 60;
@@ -1108,61 +920,75 @@ class _WorkoutFinishScreenState extends State
// thumbnail buried at the end among the strain/zone/PR cards.
final hasRoute = _route != null && _route!.hasPath;
+ // PERFORMANCE: the ListView is deliberately NOT wrapped in one big
+ // AnimatedBuilder any more. It used to be — which meant every frame of the
+ // 2.6 s reveal rebuilt the entire screen, including the FlutterMap and (via
+ // RouteCard) a full O(N) re-derivation of the route geometry. On an hour-long
+ // ride that was hundreds of thousands of trig ops and allocations per second,
+ // and it is the single reason this screen felt broken.
+ //
+ // Now each section owns a small [_Reveal] that animates only opacity and
+ // offset around an already-built `child`, so the expensive subtrees are
+ // constructed exactly once.
return Scaffold(
backgroundColor: AppColors.background,
body: Stack(
children: [
SafeArea(
- child: AnimatedBuilder(
- animation: _reveal,
- builder: (context, _) => ListView(
- padding: const EdgeInsets.fromLTRB(
- Sp.screen, Sp.x6, Sp.screen, Sp.x10),
- children: [
- // Opaque background so the shared PNG never captures
- // transparency.
- RepaintBoundary(
- key: _cardKey,
- child: Container(
- color: AppColors.background,
- padding: const EdgeInsets.symmetric(vertical: Sp.x2),
- child: Column(
- children: [
- _header(s),
- if (hasRoute) ...[
- const SizedBox(height: Sp.x5),
- _heroRoute(),
- ],
- const SizedBox(height: Sp.x6),
- _strainGauge(strain),
- const SizedBox(height: Sp.x7),
- _heroStats(peak, avg, kcal, steps),
- const SizedBox(height: Sp.x7),
- _zoneCard(bands),
- if (curve.isNotEmpty) ...[
- const SizedBox(height: Sp.x5),
- _hrrCard(curve),
- ],
- if (_prWorkout || _prSteps) ...[
- const SizedBox(height: Sp.x5),
- _prBadges(),
- ],
- // The old small map thumbnail only shows for
- // non-GPS workouts / no route (its own graceful
- // empty state) — a real route is already the hero
- // above, not duplicated down here.
- if (!hasRoute) ...[
- const SizedBox(height: Sp.x5),
- _mapSlot(),
- ],
+ child: ListView(
+ padding: const EdgeInsets.fromLTRB(
+ Sp.screen, Sp.x6, Sp.screen, Sp.x10),
+ children: [
+ // Kept as a RepaintBoundary purely to isolate this subtree's
+ // repaints — it is no longer a capture target. Sharing now
+ // composes its own image (workout_share_card.dart) instead of
+ // rasterising this screen.
+ RepaintBoundary(
+ child: Container(
+ color: AppColors.background,
+ padding: const EdgeInsets.symmetric(vertical: Sp.x2),
+ child: Column(
+ children: [
+ _header(s),
+ if (hasRoute) ...[
+ const SizedBox(height: Sp.x5),
+ _heroRoute(),
+ const SizedBox(height: Sp.x5),
+ _routeStatRow(),
],
- ),
+ const SizedBox(height: Sp.x6),
+ _strainGauge(strain),
+ const SizedBox(height: Sp.x7),
+ _heroStats(peak, avg, kcal, steps),
+ const SizedBox(height: Sp.x7),
+ _zoneCard(bands),
+ if (hasRoute) ...[
+ const SizedBox(height: Sp.x5),
+ _splitsCard(),
+ ],
+ if (curve.isNotEmpty) ...[
+ const SizedBox(height: Sp.x5),
+ _hrrCard(curve),
+ ],
+ if (_prWorkout || _prSteps) ...[
+ const SizedBox(height: Sp.x5),
+ _prBadges(),
+ ],
+ // The old small map thumbnail only shows for
+ // non-GPS workouts / no route (its own graceful
+ // empty state) — a real route is already the hero
+ // above, not duplicated down here.
+ if (!hasRoute) ...[
+ const SizedBox(height: Sp.x5),
+ _mapSlot(),
+ ],
+ ],
),
),
- const SizedBox(height: Sp.x7),
- _actions(),
- ],
- ),
+ ),
+ const SizedBox(height: Sp.x7),
+ _actions(),
+ ],
),
),
// Confetti — only after a PR pops.
@@ -1189,9 +1015,10 @@ class _WorkoutFinishScreenState extends State
final label = s.type.isEmpty
? 'Workout'
: s.type[0].toUpperCase() + s.type.substring(1);
- return Opacity(
- opacity: _seg(0.0, 0.3),
- child: Column(
+ return _reveals(
+ 0.0,
+ 0.3,
+ Column(
children: [
Text('$label complete', style: AppText.h1),
const SizedBox(height: Sp.x1),
@@ -1201,58 +1028,65 @@ class _WorkoutFinishScreenState extends State
);
}
- Widget _strainGauge(double strain) {
- final p = _seg(0.0, 0.5);
- return Center(
- child: ArcGauge(
- value: (strain / 21).clamp(0.0, 1.0),
- color: AppColors.accent,
- size: 176,
- stroke: 15,
- sweepFraction: 0.75,
- endDot: true,
- center: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Text((strain * p).toStringAsFixed(1), style: AppText.display),
- Text('STRAIN', style: AppText.overline),
- ],
+ Widget _strainGauge(double strain) => Center(
+ child: ArcGauge(
+ value: (strain / 21).clamp(0.0, 1.0),
+ color: AppColors.accent,
+ size: 176,
+ stroke: 15,
+ sweepFraction: 0.75,
+ endDot: true,
+ // Only the counting number rebuilds — not the gauge around it.
+ center: AnimatedBuilder(
+ animation: _reveal,
+ builder: (context, _) => Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text((strain * _seg(0.0, 0.5)).toStringAsFixed(1),
+ style: AppText.display),
+ Text('STRAIN', style: AppText.overline),
+ ],
+ ),
+ ),
),
- ),
- );
- }
+ );
+ /// These figures COUNT UP with the reveal, so unlike the other sections they
+ /// legitimately rebuild per frame — but it is a handful of Text widgets, not
+ /// a map or a route re-derivation.
Widget _heroStats(int peak, int? avg, int kcal, int steps) {
- final p = _seg(0.15, 0.6);
- Widget stat(String v, String label) => Expanded(
- child: Column(
- children: [
- Text(v, style: AppText.metric.copyWith(fontSize: 24)),
- const SizedBox(height: 2),
- Text(label, style: AppText.overline.copyWith(fontSize: 9)),
- ],
+ Widget stat(String v, String label) =>
+ Expanded(child: _FinishStat(v, label));
+ return AnimatedBuilder(
+ animation: _reveal,
+ builder: (context, _) {
+ final p = _seg(0.15, 0.6);
+ return Opacity(
+ opacity: p,
+ child: Transform.translate(
+ offset: Offset(0, 14 * (1 - p)),
+ child: Row(
+ children: [
+ stat(peak > 0 ? '${(peak * p).round()}' : '—', 'PEAK BPM'),
+ stat(avg != null ? '${(avg * p).round()}' : '—', 'AVG BPM'),
+ stat('${(kcal * p).round()}', 'KCAL'),
+ if (steps > 0) stat('${(steps * p).round()}', 'STEPS'),
+ ],
+ ),
),
);
- return Opacity(
- opacity: p,
- child: Transform.translate(
- offset: Offset(0, 14 * (1 - p)),
- child: Row(
- children: [
- stat(peak > 0 ? '${(peak * p).round()}' : '—', 'PEAK BPM'),
- stat(avg != null ? '${(avg * p).round()}' : '—', 'AVG BPM'),
- stat('${(kcal * p).round()}', 'KCAL'),
- if (steps > 0) stat('${(steps * p).round()}', 'STEPS'),
- ],
- ),
- ),
+ },
);
}
- Widget _zoneCard(List