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:

+ +

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 bands) { + Widget _zoneCard(List bands) => AnimatedBuilder( + animation: _reveal, + builder: (context, _) => _zoneCardBody(bands, _seg(0.4, 0.7)), + ); + + Widget _zoneCardBody(List bands, double wipe) { final vals = [for (final b in bands) (b['min'] as num?)?.toDouble() ?? 0]; final colors = [for (int i = 0; i < bands.length; i++) AppColors.zone(i)]; - final wipe = _seg(0.4, 0.7); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -1305,8 +1139,12 @@ class _WorkoutFinishScreenState extends State ); } - Widget _hrrCard(List curve) { - final p = _seg(0.55, 0.85); + Widget _hrrCard(List curve) => AnimatedBuilder( + animation: _reveal, + builder: (context, _) => _hrrCardBody(curve, _seg(0.55, 0.85)), + ); + + Widget _hrrCardBody(List curve, double p) { // Build normalized points: x by sec, y by drop (more drop → higher). final pts = [const Offset(0, 0)]; var maxSec = 1.0, maxDrop = 1.0; @@ -1338,7 +1176,9 @@ class _WorkoutFinishScreenState extends State ), const SizedBox(height: Sp.x2), Opacity( - opacity: _seg(0.75, 0.9), + // Same timeline slice, read from the p already threaded in. + opacity: Interval(0.75, 0.9, curve: Curves.easeOutCubic) + .transform(_reveal.value), child: Row( children: [ for (final c in curve) @@ -1362,29 +1202,35 @@ class _WorkoutFinishScreenState extends State /// PRs land as the refs' engraved medal cards (restrained metal on ink), /// with one slow celebrate pass — the confetti burst stays the only fanfare. - Widget _prBadges() { - final pop = Curves.easeOutBack.transform(_seg(0.75, 1.0).clamp(0.0, 1.0)); - return Transform.scale( - scale: pop.clamp(0.0, 1.0), - child: Column( - children: [ - if (_prWorkout) - const MedalCard( - medal: 'PR', - overline: 'Personal record', - title: 'Hardest workout yet', - subtitle: 'Your highest strain on record', - ).dsCelebrate(), - if (_prWorkout && _prSteps) const SizedBox(height: Sp.x3), - if (_prSteps) - const MedalCard( - medal: 'PR', - overline: 'Personal record', - title: 'Most steps in a workout', - subtitle: 'Your biggest step count on record', - ).dsCelebrate(), - ], - ), + Widget _prBadges() => AnimatedBuilder( + animation: _reveal, + child: _prBadgesBody(), + builder: (context, built) { + final pop = + Curves.easeOutBack.transform(_seg(0.75, 1.0).clamp(0.0, 1.0)); + return Transform.scale(scale: pop.clamp(0.0, 1.0), child: built); + }, + ); + + Widget _prBadgesBody() { + return Column( + children: [ + if (_prWorkout) + const MedalCard( + medal: 'PR', + overline: 'Personal record', + title: 'Hardest workout yet', + subtitle: 'Your highest strain on record', + ).dsCelebrate(), + if (_prWorkout && _prSteps) const SizedBox(height: Sp.x3), + if (_prSteps) + const MedalCard( + medal: 'PR', + overline: 'Personal record', + title: 'Most steps in a workout', + subtitle: 'Your biggest step count on record', + ).dsCelebrate(), + ], ); } @@ -1393,26 +1239,65 @@ class _WorkoutFinishScreenState extends State /// [RouteCard] (map + distance/pace stats) — same widget the workout /// detail screen already uses, so this stays visually consistent rather /// than reinventing the stat formatting. - Widget _heroRoute() { - return Opacity( - opacity: _seg(0.05, 0.4), - child: RouteCard(route: _route!, maxHr: _maxHr), + Widget _heroRoute() => + _reveals(0.05, 0.4, RouteCard(route: _route!, maxHr: _maxHr)); + + /// The three numbers a runner or rider looks for FIRST, given the same + /// weight as the strain ring rather than buried in RouteCard's footer: + /// distance, moving time, and pace (runs/walks) or average speed (rides). + /// Garmin/Strava lead with exactly this row; we had it nowhere on the finish + /// screen at all. + Widget _routeStatRow() { + final route = _route!; + final units = context.watch(); + final isRide = _isRideType(widget.snapshot.type); + final moving = Duration(seconds: route.movingSec); + final paceText = units.pace(route.distanceMeters, route.movingSec); + final speeds = [ + for (final p in route.points) + if (p.speed != null && p.speed! >= 0) p.speed!, + ]; + final avgSpeed = speeds.isEmpty + ? null + : speeds.reduce((a, b) => a + b) / speeds.length; + final third = isRide && avgSpeed != null + ? (units.speed(avgSpeed), 'AVG SPEED') + : (paceText, 'AVG PACE'); + return _reveals( + 0.1, + 0.45, + Row( + children: [ + Expanded( + child: _FinishStat(units.distance(route.distanceMeters), 'DISTANCE'), + ), + Expanded(child: _FinishStat(_dur(moving), 'MOVING')), + Expanded(child: _FinishStat(third.$1, third.$2)), + ], + ), ); } + static bool _isRideType(String t) => + t == 'cycling' || t == 'ride' || t == 'bike' || t == 'biking'; + + /// Per-km/mi splits — the thing every serious run/ride app shows on the + /// summary and this screen simply did not have. [SplitsTable] already + /// existed and was only reachable from the workout detail screen. + Widget _splitsCard() => + _reveals(0.55, 0.85, SplitsTable(route: _route!, maxHr: _maxHr)); + Widget _mapSlot() { final verts = _routeVertices; if (verts != null && verts.length >= 2) { // Real route recorded → static HR-zone-coloured thumbnail. - return Opacity( - opacity: _seg(0.6, 0.9), - child: RouteMapView(vertices: verts, height: 140), - ); + return _reveals(0.6, 0.9, RouteMapView(vertices: verts, height: 140)); } // No route (indoor / permission denied / non-GPS type) → graceful empty. - return Opacity( - opacity: _seg(0.6, 0.9), - child: Container( + return _reveals( + 0.6, + 0.9, + Container( height: 96, decoration: BoxDecoration( color: AppColors.surfaceAlt.withValues(alpha: 0.5), @@ -1434,72 +1319,101 @@ class _WorkoutFinishScreenState extends State } Widget _actions() { - return Opacity( - opacity: _seg(0.85, 1.0), - child: Row( + return _reveals( + 0.85, + 1.0, + // Share is the PRIMARY action here, not the secondary one it used to be. + // Finishing a workout you're proud of and wanting to post it is the most + // common thing to do from this screen; "full breakdown" is the + // considered, later action and reads fine as a quiet link. + Column( children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: _sharing ? null : _share, - icon: _sharing - ? SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, color: AppColors.accent)) - : const Icon(Icons.ios_share_rounded, size: 18), - label: Text(_sharing ? 'Preparing…' : 'Share'), + SizedBox( + width: double.infinity, + height: 54, + child: FilledButton.icon( + onPressed: _share, + icon: const Icon(Icons.ios_share_rounded, size: 19), + label: const Text('Share workout'), ), ), - const SizedBox(width: Sp.x3), - Expanded( - child: FilledButton( - onPressed: () => Navigator.of(context).pushReplacement( - themedRoute((_) => WorkoutDetailScreen(id: widget.id), - name: 'WorkoutDetailScreen'), - ), - child: const Text('Full breakdown'), + const SizedBox(height: Sp.x3), + TextButton( + onPressed: () => Navigator.of(context).pushReplacement( + themedRoute((_) => WorkoutDetailScreen(id: widget.id), + name: 'WorkoutDetailScreen'), ), + child: const Text('Full breakdown'), ), ], ), ); } + /// Build the share composition and open the preview. + /// + /// This used to rasterise `_cardKey` — the entire finish card, header through + /// PR badges — and hand the PNG straight to the OS sheet. Two problems: it + /// was a screenshot of a dashboard rather than something worth posting, and + /// the athlete never saw it before it landed in the composer. Now the image + /// is composed for sharing (see workout_share_card.dart) and previewed first. + /// Compose the share image and open the preview. + /// + /// This used to rasterise `_cardKey` — the entire finish card, header through + /// PR badges — and hand the PNG straight to the OS sheet. Two problems: it + /// was a screenshot of a dashboard rather than something worth posting, and + /// the athlete never saw it before it landed in the composer. + /// + /// The composition itself lives in [buildWorkoutShareData] so this screen and + /// the workout DETAIL screen produce byte-identical cards for the same + /// workout — sharing the same run from two places must not give two results. Future _share() async { - setState(() => _sharing = true); - try { - final box = context.findRenderObject() as RenderBox?; - final origin = (box != null && box.hasSize) - ? (box.localToGlobal(Offset.zero) & box.size) - : null; - final boundary = - _cardKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; - if (boundary == null) throw StateError('Card not ready'); - final ui.Image image = await boundary.toImage(pixelRatio: 3); - final ByteData? bytes = - await image.toByteData(format: ui.ImageByteFormat.png); - if (bytes == null) throw StateError('Failed to encode image'); - final dir = await getTemporaryDirectory(); - final file = File( - '${dir.path}/openstrap_workout_${DateTime.now().millisecondsSinceEpoch}.png'); - await file.writeAsBytes(bytes.buffer.asUint8List()); - await Share.shareXFiles( - [XFile(file.path)], - text: 'My OpenStrap workout', - sharePositionOrigin: origin, - ); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text("Couldn't share: $e"))); - } finally { - if (mounted) setState(() => _sharing = false); - } + final s = widget.snapshot; + final d = _detail; + final data = buildWorkoutShareData( + units: context.read(), + type: s.type, + duration: s.duration, + when: DateTime.now(), + maxHr: _maxHr, + strain: (d?['strain'] as num?)?.toDouble() ?? s.strain, + calories: (d?['calories'] as num?)?.toInt() ?? s.calories.round(), + route: _route, + avgHr: (d?['avg_hr'] as num?)?.toInt(), + ); + if (!mounted) return; + await Navigator.of(context).push( + themedRoute((_) => WorkoutSharePreviewScreen(data: data), + name: 'WorkoutSharePreviewScreen'), + ); } + } /// Self-drawing HR-recovery polyline — draws up to [progress] of its length. +/// One headline figure on the finish summary: the number in tabular metric +/// type, the label whispered underneath. Same rhythm as the design system's +/// [BigStat]/overline pairing, sized for a three-across row. +class _FinishStat extends StatelessWidget { + final String value; + final String label; + const _FinishStat(this.value, this.label); + + @override + Widget build(BuildContext context) => Column( + children: [ + Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppText.metric.copyWith(fontSize: 22), + ), + const SizedBox(height: 3), + Text(label, style: AppText.overline.copyWith(fontSize: 9)), + ], + ); +} + class _HrrCurvePainter extends CustomPainter { final List points; // normalized 0..1 (y already screen-oriented) final double progress; // 0..1 @@ -1557,10 +1471,11 @@ class _LiveRouteMap extends StatelessWidget { final Duration elapsed; final int hr; final int zoneIndex; - // False in the real live session — the merged _ControlPanel/_GpsControlPanel - // shows these same live stats in ONE glass card instead. True (the - // default) for the Design Gallery's standalone preview, which has no - // control panel of its own. + // False in the real live session — the metric sheet below the map shows + // these same live stats, so a second bar on the map would duplicate them. + // True (the default) for the Design Gallery's standalone preview, which has + // no sheet of its own; it also switches on the map's own recording pill and + // shifts the re-centre button up to clear the bar. final bool showStatBar; const _LiveRouteMap({ required this.tracker, @@ -1736,12 +1651,9 @@ class _GpsLiveMapViewState extends State { final path = widget.vertices; if (path.isNotEmpty) _follow(path); final zone = widget.zoneIndex.clamp(0, 5); - final zoneColor = AppColors.zone(zone); - final movingSec = widget.movingSeconds; - final avgPace = units.pace( - widget.distanceMeters, - movingSec > 0 ? movingSec : widget.elapsed.inSeconds, - ); + final zoneColor = AppColors.zoneOnDark(zone); + // Moving pace, not elapsed pace — see the note in _GpsControlPanel. + final avgPace = units.pace(widget.distanceMeters, widget.movingSeconds); final livePace = units.paceFromSpeed(widget.currentSpeedMps); return ClipRRect( @@ -1792,9 +1704,12 @@ class _GpsLiveMapViewState extends State { // design — it's a full-bleed background), so its OWN overlays // must add the safe-area top inset themselves or they render // under/behind the status bar / notch, unreadable. + // Sits BELOW the session screen's top rail (state chip + map + // toggle), which occupies roughly the first 56 px of safe area. + // At the rail's own offset these overlapped. if (path.isNotEmpty && (widget.stalled || widget.error != null)) Positioned( - top: MediaQuery.of(context).padding.top + Sp.x3, + top: MediaQuery.of(context).padding.top + 64, left: Sp.x3, right: Sp.x3, child: Center( @@ -1813,37 +1728,27 @@ class _GpsLiveMapViewState extends State { ), ), ), - // iOS v1 is while-in-use location only — fixes stop when the screen - // locks. Say so instead of silently producing a gappy route. + // Recording state. This slot used to carry "Keep the screen on to + // map your route" — an instruction that only existed because iOS + // suspended us the moment the screen locked. That is fixed (the + // `location` background mode + a session-scoped wake flag), so the + // banner would now be actively FALSE. It is replaced by a plain + // statement of what is happening, which is what the athlete + // actually wants to know at a glance. // Mutually exclusive with the stall/error banner above — they used - // to share the exact same top position unconditionally, so on iOS - // whenever BOTH applied (a stall, which is common while genuinely - // stationary/idle) they rendered directly on top of each other, - // unreadable. - if (Platform.isIOS && !(path.isNotEmpty && (widget.stalled || widget.error != null))) - Positioned( - top: MediaQuery.of(context).padding.top + Sp.x3, - left: Sp.x3, - right: Sp.x3, - child: Center( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: Sp.x3, vertical: 4), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.45), - borderRadius: BorderRadius.circular(R.chip), - ), - child: Text('Keep the screen on to map your route', - style: AppText.captionMuted - .copyWith(color: Colors.white70)), - ), - ), - ), + // to share the exact same top position unconditionally, so whenever + // BOTH applied (a stall is common while genuinely stationary) they + // rendered directly on top of each other, unreadable. // Re-centre button (appears once the user pans away). if (_userPanned) Positioned( right: Sp.x3, - bottom: 96, + // The map is bounded by the hero region now and the metric sheet + // is a SIBLING beneath it, not an overlay — so this only has to + // clear the map's own bottom edge. It used to be pinned at + // `bottom: 96` against the whole screen while the control panel + // was far taller than that, which put this button underneath it. + bottom: widget.showStatBar ? 96 : Sp.x4, child: GestureDetector( onTap: () { setState(() { @@ -1853,13 +1758,14 @@ class _GpsLiveMapViewState extends State { _follow(path); }, child: Container( - padding: const EdgeInsets.all(Sp.x2), + padding: const EdgeInsets.all(Sp.x3), decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.55), + color: Colors.black.withValues(alpha: 0.62), shape: BoxShape.circle, + border: Border.all(color: Colors.white24), ), - child: const Icon(Icons.my_location, - size: 18, color: Colors.white), + child: const Icon(Icons.my_location_rounded, + size: 20, color: Colors.white), ), ), ), @@ -1886,36 +1792,51 @@ class _GpsLiveMapViewState extends State { ], ), ), - child: Row( + // HIERARCHY: distance is the primary figure and is set two + // steps larger than the rest. Four equal-weight numbers (the + // old layout) give the eye nothing to land on at a glance — + // and a glance, mid-stride or on a bar mount, is all this + // screen ever gets. Heart rate is a zone-tinted pill rather + // than a fourth column, so the zone reads as colour before + // the number is even parsed. + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: _RouteLiveStat( - value: units.distance(widget.distanceMeters), - label: 'distance', - ), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: _RouteLiveStat( + value: units.distance(widget.distanceMeters), + label: 'distance', + primary: true, + ), + ), + _LiveHrPill(hr: widget.hr, zoneColor: zoneColor, + zoneLabel: _zoneLabels[zone]), + ], ), - Expanded( - child: _RouteLiveStat( - value: _fmtDuration(widget.elapsed), - label: 'duration', - ), - ), - Expanded( - child: _RouteLiveStat( - // Live (instantaneous) pace when we have a fresh - // speed reading; falls back to the run's average - // pace so the field is never blank. - value: livePace == '—' ? avgPace : livePace, - label: 'pace', - valueColor: AppColors.coral, - ), - ), - Expanded( - child: _RouteLiveStat( - value: widget.hr > 0 ? '${widget.hr}' : '—', - label: _zoneLabels[zone], - valueColor: zoneColor, - ), + const SizedBox(height: Sp.x3), + Row( + children: [ + Expanded( + child: _RouteLiveStat( + value: _fmtDuration(widget.elapsed), + label: 'duration', + ), + ), + Expanded( + child: _RouteLiveStat( + // Live (instantaneous) pace when we have a fresh + // speed reading; falls back to the run's average + // pace so the field is never blank. + value: livePace == '—' ? avgPace : livePace, + label: 'pace', + valueColor: AppColors.coral, + ), + ), + ], ), ], ), @@ -1933,10 +1854,16 @@ class _RouteLiveStat extends StatelessWidget { final String value; final String label; final Color? valueColor; + + /// The one figure that carries the glance (distance) — set larger so the + /// bar has a clear first read instead of four equal numbers. + final bool primary; + const _RouteLiveStat({ required this.value, required this.label, this.valueColor, + this.primary = false, }); @override @@ -1946,19 +1873,814 @@ class _RouteLiveStat extends StatelessWidget { children: [ Text( value, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: AppText.metric.copyWith( color: valueColor ?? Colors.white, - fontSize: 20, + fontSize: primary ? 34 : 20, + height: primary ? 1.05 : null, fontFeatures: [const FontFeature.tabularFigures()], ), ), const SizedBox(height: 2), Text( label.toUpperCase(), - style: AppText.overline - .copyWith(color: Colors.white38, fontSize: 9, letterSpacing: 1), + style: AppText.overline.copyWith( + color: AppColors.onNightMuted, fontSize: 9, letterSpacing: 1), ), ], ); } } + +/// Live heart rate as a zone-tinted pill on the map's stat bar. +/// +/// The zone is carried by COLOUR first — on a bar mount at speed the tint +/// registers before any digit does, which is the whole point of the app's +/// zone language. Sits beside the primary distance figure rather than as a +/// fourth equal column. +class _LiveHrPill extends StatelessWidget { + final int hr; + final Color zoneColor; + final String zoneLabel; + const _LiveHrPill({ + required this.hr, + required this.zoneColor, + required this.zoneLabel, + }); + + @override + Widget build(BuildContext context) { + final has = hr > 0; + return Container( + padding: const EdgeInsets.symmetric(horizontal: Sp.x3, vertical: 6), + decoration: BoxDecoration( + color: zoneColor.withValues(alpha: has ? 0.22 : 0.10), + borderRadius: BorderRadius.circular(R.pill), + border: Border.all( + color: zoneColor.withValues(alpha: has ? 0.75 : 0.30), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + has ? '$hr' : '—', + style: AppText.metric.copyWith( + color: has ? zoneColor : AppColors.onNightSoft, + fontSize: 22, + height: 1.05, + fontFeatures: [const FontFeature.tabularFigures()], + ), + ), + Text( + has ? zoneLabel.toUpperCase() : 'NO BPM', + style: AppText.overline.copyWith( + color: has ? zoneColor : AppColors.onNightMuted, + fontSize: 8, + letterSpacing: 1, + ), + ), + ], + ), + ); + } +} + +// ── Live-session chrome ────────────────────────────────────────────────────── +// +// These four pieces replace what used to be a flat pile of absolutely- +// positioned Stack layers on the session screen. Each one now owns a defined +// slot in a real layout, which is what makes overlap impossible rather than +// merely unlikely. + +/// The heart-rate hero for non-GPS sessions: session clock, the beating core, +/// the zone name, and the zone ladder — composed as a COLUMN inside the +/// bounded hero area, and scaled to whatever room it actually has. +/// +/// The core used to be a fixed 270 px circle in a `Center` inside the screen's +/// root Stack, with the clock absolutely positioned above it and the control +/// panel absolutely positioned below. On a shorter phone all three collided. +/// Here the ring takes its size from a LayoutBuilder, so it shrinks instead. +class _HeroCore extends StatelessWidget { + final int hr; + final int zone; + final double hrrPct; + final Duration elapsed; + final Duration redStreak; + final String line; + final String? almostText; + final Color almostColor; + final AnimationController beat; + final AnimationController fx; + final String Function(Duration) fmt; + + const _HeroCore({ + required this.hr, + required this.zone, + required this.hrrPct, + required this.elapsed, + required this.redStreak, + required this.line, + required this.almostText, + required this.almostColor, + required this.beat, + required this.fx, + required this.fmt, + }); + + @override + Widget build(BuildContext context) { + final z = _zones[zone]; + return LayoutBuilder( + builder: (context, box) { + // The ring is whatever the hero can spare, never a fixed 270. + final ring = math.min(box.maxWidth * 0.62, box.maxHeight * 0.46) + .clamp(150.0, 260.0); + final bpmSize = ring * 0.34; + return Stack( + children: [ + // Zone-tinted studio wash, intensity climbing with effort. + 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, + ], + ), + ), + ), + ), + Positioned.fill( + child: RepaintBoundary( + child: AnimatedBuilder( + animation: fx, + builder: (context, _) => CustomPaint( + painter: _EmberPainter( + t: fx.value, intensity: hrrPct, color: z.color), + ), + ), + ), + ), + // The zone ladder gets its own reserved gutter on the right, so it + // can no longer sit on top of the ring on a narrow screen. + Positioned( + right: Sp.x4, + top: 0, + bottom: 0, + child: Center(child: _zoneLadder(zone)), + ), + Positioned.fill( + child: Padding( + // Left inset mirrors the ladder gutter so the ring stays + // optically centred between them. + padding: const EdgeInsets.fromLTRB(Sp.x8, Sp.x10, Sp.x8, Sp.x4), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + fmt(elapsed), + style: AppText.hero.copyWith( + fontSize: 40, + color: Colors.white, + letterSpacing: 0, + fontFeatures: [const FontFeature.tabularFigures()], + ), + ), + Text( + 'DURATION', + style: AppText.overline.copyWith( + color: AppColors.onNightMuted, + fontSize: 9, + letterSpacing: 3, + ), + ), + if (redStreak.inSeconds >= 5) ...[ + const SizedBox(height: Sp.x2), + _LivePill( + icon: AppIcon(OsIcon.calories, + size: 14, color: AppColors.coral), + text: '${fmt(redStreak)} in the red', + tint: AppColors.coral, + ), + ], + const Spacer(), + SizedBox( + width: ring, + height: ring, + child: Stack( + alignment: Alignment.center, + children: [ + Positioned.fill( + 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: ring * 0.78, + height: ring * 0.78, + 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: bpmSize, + color: Colors.white, + height: 1, + fontWeight: FontWeight.w900, + ), + ), + Text( + 'BPM', + style: AppText.overline.copyWith( + color: AppColors.onNightMuted, + fontSize: 11, + letterSpacing: 5, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + ], + ), + ), + const Spacer(), + AnimatedDefaultTextStyle( + duration: Motion.med, + style: AppText.h2.copyWith( + color: z.color, + letterSpacing: 3, + fontWeight: FontWeight.w900, + fontSize: 20, + ), + child: Text('${z.label} · ${z.name}'.toUpperCase()), + ), + const SizedBox(height: Sp.x2), + SizedBox( + height: 22, + child: AnimatedSwitcher( + duration: Motion.med, + child: almostText != null + ? Text( + almostText!, + key: ValueKey(almostText), + textAlign: TextAlign.center, + style: AppText.bodySoft.copyWith( + color: almostColor, + fontWeight: FontWeight.w700), + ) + : Text( + line, + key: ValueKey(line), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppText.bodySoft + .copyWith(color: AppColors.onNightSoft), + ), + ), + ), + ], + ), + ), + ), + ], + ); + }, + ); + } +} + +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: 26, + 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), + ], + ], + ); + +/// Small translucent pill used for in-hero status text. +class _LivePill extends StatelessWidget { + final Widget icon; + final String text; + final Color? tint; + const _LivePill({required this.icon, required this.text, this.tint}); + + @override + Widget build(BuildContext context) { + final c = tint ?? Colors.white; + return Container( + padding: + const EdgeInsets.symmetric(horizontal: Sp.x4, vertical: Sp.x2), + decoration: BoxDecoration( + color: c.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(R.pill), + border: Border.all(color: c.withValues(alpha: 0.18)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + icon, + const SizedBox(width: Sp.x2), + Flexible( + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppText.metricSm.copyWith( + color: c, + fontSize: 15, + letterSpacing: 0.5, + fontFeatures: [const FontFeature.tabularFigures()], + ), + ), + ), + ], + ), + ); + } +} + +/// The top rail's only occupant: a tappable warning when location is denied +/// or off for a route-eligible workout. +/// +/// There used to be a "Recording" pill here in the healthy case. It was +/// removed — it stated something the athlete already knows (they started the +/// workout, the timer is running) and cost a permanent breathing animation +/// plus a chunk of the map's most valuable screen area to say it. +class _SessionStateChip extends StatelessWidget { + final GpsPermissionStatus? locationIssue; + final Future Function() onFixLocation; + const _SessionStateChip({ + required this.locationIssue, + required this.onFixLocation, + }); + + @override + Widget build(BuildContext context) { + if (locationIssue == null) return const SizedBox.shrink(); + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onFixLocation, + child: _LivePill( + icon: const Icon(Icons.location_off_outlined, + size: 15, color: Colors.white60), + text: locationIssue == GpsPermissionStatus.serviceOff + ? 'Location off — turn it on' + : 'Location off — allow it', + tint: AppColors.warn, + ), + ); + } +} + +/// Switch between the map and the heart-rate core, centred in the metric sheet. +/// +/// A two-up segmented control rather than a single circular icon button +/// floating on the map: a segmented pair states BOTH available views and which +/// one you are on, where a lone icon button only hinted at the other one. +/// +/// Icons only. The words "Heart" and "Map" beside a heart and a map glyph were +/// pure redundancy — the icons already say it, and dropping the labels keeps +/// the control compact enough to centre without dominating the row above it. +/// The accessible names live in [Semantics] instead, where screen readers need +/// them and sighted users don't. +class _ViewToggle extends StatelessWidget { + final bool showingMap; + final ValueChanged onChanged; + const _ViewToggle({required this.showingMap, required this.onChanged}); + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(R.pill), + border: Border.all(color: Colors.white12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _seg( + icon: Icons.favorite_rounded, + label: 'Heart', + selected: !showingMap, + onTap: () => onChanged(false), + ), + _seg( + icon: Icons.map_rounded, + label: 'Map', + selected: showingMap, + onTap: () => onChanged(true), + ), + ], + ), + ); + + Widget _seg({ + required IconData icon, + required String label, + required bool selected, + required VoidCallback onTap, + }) => + Semantics( + button: true, + selected: selected, + label: label, + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: AnimatedContainer( + duration: Motion.fast, + curve: Motion.curve, + padding: const EdgeInsets.symmetric( + horizontal: Sp.x5, vertical: 8), + decoration: BoxDecoration( + color: selected + ? Colors.white.withValues(alpha: 0.14) + : Colors.transparent, + borderRadius: BorderRadius.circular(R.pill), + ), + child: Icon( + icon, + size: 18, + color: selected ? AppColors.onNight : AppColors.onNightSoft, + ), + ), + ), + ); +} + +/// The metric sheet — the bottom half of the session screen. +/// +/// Production run/ride apps all converge on the same hierarchy, and this now +/// follows it: ONE primary figure large enough to read at a glance mid-stride, +/// a zone-tinted heart-rate pill beside it, then a row of secondary stats, then +/// the finish control. The previous panel gave six stats identical weight in a +/// 2×2-plus-3 grid, every one of them tagged with the SAME generic pulse icon, +/// so nothing read first and the icons carried no information. +/// +/// It is a sibling of the hero in a Column, so it cannot overlap anything. +class _SessionSheet extends StatelessWidget { + final LiveWorkoutState workout; + final AnimationController holdController; + final bool ending; + final VoidCallback onFinished; + final int hr; + final int zoneIndex; + final Duration elapsed; + + /// GPS sessions lead with distance and show pace; non-GPS lead with the + /// clock. Null distance ⇒ not a route workout. + final String? distance; + final String? distanceUnit; + final String? pace; + + /// The map/heart switch, rendered in the sheet's footer beside the stats. + /// Null for a workout with no route to switch to. + final Widget? viewToggle; + + const _SessionSheet({ + required this.workout, + required this.holdController, + required this.ending, + required this.onFinished, + required this.hr, + required this.zoneIndex, + required this.elapsed, + this.distance, + this.distanceUnit, + this.pace, + this.viewToggle, + }); + + static String _fmtClock(Duration d) { + final m = (d.inMinutes % 60).toString().padLeft(2, '0'); + final s = (d.inSeconds % 60).toString().padLeft(2, '0'); + return d.inHours > 0 ? '${d.inHours}:$m:$s' : '$m:$s'; + } + + @override + Widget build(BuildContext context) { + final zone = zoneIndex.clamp(0, 5); + final zoneColor = AppColors.zoneOnDark(zone); + final isRoute = distance != null; + final steps = context.select((a) => a.workoutSteps); + + return Container( + decoration: BoxDecoration( + color: AppColors.nightAlt, + border: Border(top: BorderSide(color: Colors.white10)), + borderRadius: const BorderRadius.vertical(top: Radius.circular(R.card)), + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(Sp.x6, Sp.x5, Sp.x6, Sp.x5), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // The primary figure and the heart-rate pill appear ONLY on the + // map, where the hero is a map and carries neither. In heart + // mode the hero already shows the session clock at 40 px and the + // BPM at ring scale with its zone name — repeating both down + // here was the same number printed twice on one screen. + if (isRoute) ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: _PrimaryMetric( + value: distance!, + unit: distanceUnit ?? '', + ), + ), + _LiveHrPill( + hr: hr, + zoneColor: zoneColor, + zoneLabel: _zones[zone].label, + ), + ], + ), + const SizedBox(height: Sp.x5), + const Divider(color: Colors.white10, height: 1), + const SizedBox(height: Sp.x4), + ], + // Secondary stats — three across, evenly weighted and centred. + Row( + children: [ + Expanded( + child: _SheetStat( + isRoute ? _fmtClock(elapsed) : '${workout.calories.round()}', + isRoute ? 'TIME' : 'KCAL', + ), + ), + Expanded( + child: _SheetStat( + isRoute ? (pace ?? '—') : workout.strain.toStringAsFixed(1), + isRoute ? 'PACE' : 'STRAIN', + ), + ), + Expanded( + child: _SheetStat( + isRoute ? '${workout.calories.round()}' : '$steps', + isRoute ? 'KCAL' : 'STEPS', + ), + ), + ], + ), + if (viewToggle != null) ...[ + const SizedBox(height: Sp.x4), + Center(child: viewToggle!), + ], + const SizedBox(height: Sp.x5), + _HoldToFinish( + holdController: holdController, + ending: ending, + onFinished: onFinished, + ), + ], + ), + ), + ), + ); + } +} + +/// The one figure the athlete reads at a glance. Sized so it survives being +/// looked at from a bar mount at speed. +class _PrimaryMetric extends StatelessWidget { + final String value; + final String unit; + const _PrimaryMetric({required this.value, required this.unit}); + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + value, + maxLines: 1, + style: AppText.display.copyWith( + fontSize: 52, + height: 1.0, + color: AppColors.onNight, + fontWeight: FontWeight.w900, + fontFeatures: [const FontFeature.tabularFigures()], + ), + ), + ), + const SizedBox(height: 2), + Text( + unit.toUpperCase(), + style: AppText.overline.copyWith( + color: AppColors.onNightMuted, + fontSize: 9, + letterSpacing: 3, + ), + ), + ], + ); +} + +/// One secondary stat in the sheet. No icon — six stats all carrying the same +/// generic pulse glyph was noise, not information. +class _SheetStat extends StatelessWidget { + final String value; + final String label; + const _SheetStat(this.value, this.label); + + @override + Widget build(BuildContext context) => Column( + children: [ + Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppText.metric.copyWith( + color: AppColors.onNight, + fontSize: 22, + fontFeatures: [const FontFeature.tabularFigures()], + ), + ), + const SizedBox(height: 2), + Text( + label, + style: AppText.overline.copyWith( + color: AppColors.onNightMuted, + fontSize: 9, + letterSpacing: 2, + ), + ), + ], + ); +} + +/// Hold-to-finish. Deliberately kept as a HOLD rather than a tap: ending a +/// session is destructive and a mis-tap mid-run costs the workout. +class _HoldToFinish extends StatelessWidget { + final AnimationController holdController; + final bool ending; + final VoidCallback onFinished; + const _HoldToFinish({ + required this.holdController, + required this.ending, + required this.onFinished, + }); + + @override + Widget build(BuildContext context) => Semantics( + button: true, + label: 'Hold to finish workout', + child: 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: 64, + decoration: BoxDecoration( + color: val > 0 + ? Colors.white.withValues(alpha: 0.1) + : Colors.white.withValues(alpha: 0.04), + 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(AppColors.onNightSoft, + AppColors.onNight, val)), + const SizedBox(width: Sp.x3), + Text( + ending ? 'FINISHING…' : 'HOLD TO FINISH', + style: AppText.label.copyWith( + color: Color.lerp( + AppColors.onNightSoft, AppColors.onNight, val), + fontWeight: FontWeight.w900, + letterSpacing: 3, + fontSize: 13, + ), + ), + ], + ), + ], + ), + ), + ); + }, + ), + ), + ); +} diff --git a/lib/ui/activity/workout_share_card.dart b/lib/ui/activity/workout_share_card.dart new file mode 100644 index 00000000..99807480 --- /dev/null +++ b/lib/ui/activity/workout_share_card.dart @@ -0,0 +1,610 @@ +// The workout share card + its preview screen. +// +// WHY THIS EXISTS AS ITS OWN COMPOSITION +// +// Sharing used to capture the finish screen's card wholesale: the header, the +// route, the strain gauge, the peak/avg/kcal/steps row, the time-in-zones bar, +// the heart-rate-recovery curve and any PR badges — everything, in one tall +// PNG. That is a screenshot of a dashboard, not something anyone wants on a +// feed. It also meant the single most share-worthy thing in the whole workout, +// the route, arrived as a thumbnail sandwiched between charts. +// +// A share image has exactly one job: be worth looking at in someone else's +// feed at thumbnail size. So this is composed for that job rather than reused +// from the screen — +// +// • the map is FULL BLEED and owns the frame; everything else sits on a +// scrim over it, +// • one headline number (distance), +// • three supporting stats, no more, +// • no gauges, no curves, no badges, no zone bars — none of it survives +// being scaled to a feed thumbnail anyway, +// • aspect ratios that match where these actually get posted. +// +// Workouts with no route (indoor, treadmill, permission denied) get the same +// composition with a zone-washed panel in place of the map, so the layout and +// the code path stay identical rather than forking into a second design. + +import 'dart:io'; +import 'dart:typed_data' show ByteData; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../../gps/route_math.dart' as rmath; +import '../../gps/route_models.dart'; +import '../../state/units_controller.dart'; +import '../../theme/theme.dart'; +import '../../theme/tokens.dart'; +import '../kit/kit.dart' show AppIcon, OsIcon; +import '../kit/route_map.dart'; + +/// Where the image is going. Strava-style: a feed post and a story, because +/// those are the two shapes people actually post into and a 1:1 crop of a +/// portrait card loses the route. +enum ShareFormat { + /// 4:5 — the tallest a feed post can be without being cropped. + feed('Post', 4 / 5), + + /// 9:16 — full-bleed story. + story('Story', 9 / 16); + + const ShareFormat(this.label, this.aspect); + final String label; + final double aspect; +} + +/// Everything the card renders. Values arrive pre-formatted so the card stays +/// a pure, testable composition with no unit/locale logic of its own. +class WorkoutShareData { + /// "Morning Run", "Evening Ride" — the human title. + final String title; + + /// Quiet date line under the title. + final String subtitle; + + /// The route. Empty ⇒ the no-map composition. + final List vertices; + + /// Headline figure, split so the unit can be set smaller ("8.42" + "KM"). + final String heroValue; + final String heroUnit; + + /// Up to three supporting stats — (value, label). + final List<(String, String)> stats; + + /// Tints the no-map fallback and the accent rule. Defaults to the brand. + final Color accent; + + const WorkoutShareData({ + required this.title, + required this.subtitle, + required this.vertices, + required this.heroValue, + required this.heroUnit, + required this.stats, + required this.accent, + }); + + bool get hasRoute => vertices.length >= 2; +} + +/// The card itself. Fixed logical width; height follows [format]. +/// +/// Rendered on screen in the preview (never offscreen) so the map tiles are +/// genuinely loaded by the time the user taps Share — capturing a hidden +/// FlutterMap is a race against tile loading that produces half-blank images. +class WorkoutShareCard extends StatelessWidget { + final WorkoutShareData data; + final ShareFormat format; + + /// Logical width the card lays out at. The capture scales from this. + static const double kWidth = 360; + + const WorkoutShareCard({ + super.key, + required this.data, + required this.format, + }); + + @override + Widget build(BuildContext context) { + final height = kWidth / format.aspect; + return SizedBox( + width: kWidth, + height: height, + child: ClipRRect( + borderRadius: BorderRadius.circular(R.card), + child: Stack( + fit: StackFit.expand, + children: [ + // ── The map owns the frame ────────────────────────────────── + if (data.hasRoute) + RouteMapView( + vertices: data.vertices, + borderRadius: BorderRadius.zero, + ) + else + _NoRouteBackdrop(accent: data.accent), + + // A scrim only where type sits, so the top of the route stays + // clean. Two stops, weighted low — a full-height gradient greys + // the whole map out, which is what makes these cards look muddy. + Positioned( + left: 0, + right: 0, + bottom: 0, + child: IgnorePointer( + child: Container( + height: height * 0.52, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.night.withValues(alpha: 0.0), + AppColors.night.withValues(alpha: 0.72), + AppColors.night.withValues(alpha: 0.94), + ], + stops: const [0.0, 0.55, 1.0], + ), + ), + ), + ), + ), + + // ── Content ──────────────────────────────────────────────── + Positioned( + left: Sp.x5, + right: Sp.x5, + bottom: Sp.x5, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data.title.toUpperCase(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppText.overline.copyWith( + color: AppColors.onNightMuted, + fontSize: 10, + letterSpacing: 2.5, + ), + ), + const SizedBox(height: Sp.x2), + // Headline: the number, with its unit set small beside it so + // the figure itself reads at thumbnail size. + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + data.heroValue, + maxLines: 1, + style: AppText.display.copyWith( + color: AppColors.onNight, + fontSize: 64, + height: 1.0, + fontWeight: FontWeight.w900, + fontFeatures: [ + const FontFeature.tabularFigures() + ], + ), + ), + ), + ), + if (data.heroUnit.isNotEmpty) ...[ + const SizedBox(width: Sp.x2), + Text( + data.heroUnit.toUpperCase(), + style: AppText.h2.copyWith( + color: AppColors.onNightSoft, + fontSize: 18, + fontWeight: FontWeight.w800, + letterSpacing: 1, + ), + ), + ], + ], + ), + const SizedBox(height: Sp.x4), + Container(height: 2, width: 34, color: data.accent), + const SizedBox(height: Sp.x4), + Row( + children: [ + for (final (value, label) in data.stats) + Expanded(child: _ShareStat(value: value, label: label)), + ], + ), + const SizedBox(height: Sp.x4), + Row( + children: [ + AppIcon(OsIcon.activity, size: 13, color: data.accent), + const SizedBox(width: Sp.x2), + Text( + 'OpenStrap', + style: AppText.caption.copyWith( + color: AppColors.onNightMuted, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), + ), + const Spacer(), + Text( + data.subtitle, + style: AppText.caption + .copyWith(color: AppColors.onNightMuted), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _ShareStat extends StatelessWidget { + final String value; + final String label; + const _ShareStat({required this.value, required this.label}); + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppText.metric.copyWith( + color: AppColors.onNight, + fontSize: 19, + fontFeatures: [const FontFeature.tabularFigures()], + ), + ), + const SizedBox(height: 1), + Text( + label.toUpperCase(), + style: AppText.overline.copyWith( + color: AppColors.onNightMuted, + fontSize: 8, + letterSpacing: 1.6, + ), + ), + ], + ); +} + +/// Stand-in for the map on an indoor/no-GPS workout. Deliberately the same +/// composition, not a different card: a quiet zone-tinted field so the type +/// below still has something to sit on. +class _NoRouteBackdrop extends StatelessWidget { + final Color accent; + const _NoRouteBackdrop({required this.accent}); + + @override + Widget build(BuildContext context) => DecoratedBox( + decoration: BoxDecoration( + gradient: RadialGradient( + center: const Alignment(0, -0.35), + radius: 1.1, + colors: [ + Color.alphaBlend( + accent.withValues(alpha: 0.34), AppColors.night), + AppColors.night, + ], + ), + ), + ); +} + +/// Preview-then-share, the way every app that takes sharing seriously does it. +/// +/// Tapping Share used to silently rasterise the finish screen and hand the PNG +/// straight to the OS sheet — the athlete never saw what they were about to +/// post until it was already in the composer. Here the card is on screen at +/// real proportions, the format is switchable, and Share is the one obvious +/// action. +/// +/// It also removes a real failure mode: capturing a map that was never on +/// screen races tile loading and yields half-blank images. What you see here +/// is literally the thing that gets captured. +class WorkoutSharePreviewScreen extends StatefulWidget { + final WorkoutShareData data; + const WorkoutSharePreviewScreen({super.key, required this.data}); + + @override + State createState() => + _WorkoutSharePreviewScreenState(); +} + +class _WorkoutSharePreviewScreenState extends State { + final GlobalKey _captureKey = GlobalKey(); + ShareFormat _format = ShareFormat.feed; + bool _sharing = false; + + Future _share() async { + if (_sharing) return; + setState(() => _sharing = true); + try { + final boundary = _captureKey.currentContext?.findRenderObject() + as RenderRepaintBoundary?; + if (boundary == null) throw StateError('Card not ready'); + + // Let the current frame finish so the boundary definitely has a layer to + // rasterise. The card has been on screen since this route opened, so this + // is belt-and-braces rather than the main defence. + // + // This deliberately does NOT use `boundary.debugNeedsPaint`. That getter + // is debug-only: + // + // bool get debugNeedsPaint { + // late bool result; + // assert(() { result = _needsPaint; return true; }()); + // return result; + // } + // + // In release and profile builds the assert is stripped, `result` is never + // assigned, and reading it throws LateInitializationError — so sharing + // worked in debug and failed on every real build. Nothing catches this: + // the analyzer is happy, and the whole test suite runs in debug mode. + // Treat any `debug*` member as unusable outside an assert. + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + + // Target ~1080 px wide — the native width of a feed post; anything more + // is bytes nobody sees. + final pixelRatio = 1080 / WorkoutShareCard.kWidth; + final ui.Image image = await boundary.toImage(pixelRatio: pixelRatio); + final ByteData? bytes = + await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + if (bytes == null) throw StateError('Failed to encode image'); + + final dir = await getTemporaryDirectory(); + // ONE reused filename, not a timestamped file per share. Each capture is + // a ~1080 px PNG; a unique name per tap left every one of them sitting in + // the temp directory until the OS felt like reclaiming it. The share + // sheet has finished reading the file before the next share overwrites. + final file = File('${dir.path}/openstrap_share.png'); + await file.writeAsBytes(bytes.buffer.asUint8List()); + + if (!mounted) return; + final box = context.findRenderObject() as RenderBox?; + final origin = (box != null && box.hasSize) + ? (box.localToGlobal(Offset.zero) & box.size) + : null; + // No caption text: the image carries everything, and a canned + // "My OpenStrap workout" string is exactly the kind of filler that makes + // a share feel automated. + await Share.shareXFiles([XFile(file.path)], sharePositionOrigin: origin); + } catch (e, st) { + // Log the detail; show the athlete a fixed sentence. "Couldn't share: + // PlatformException(...)" puts an internal error string in front of + // someone who just finished a workout and can do nothing with it. + debugPrint('[share] failed: $e\n$st'); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Couldn't prepare the image — try again")), + ); + } finally { + if (mounted) setState(() => _sharing = false); + } + } + + @override + Widget build(BuildContext context) { + return Theme( + data: ThemeData.dark().copyWith(scaffoldBackgroundColor: AppColors.night), + child: Scaffold( + backgroundColor: AppColors.night, + body: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(Sp.x4, Sp.x3, Sp.x4, 0), + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).maybePop(), + icon: Icon(Icons.close_rounded, + color: AppColors.onNightSoft), + tooltip: 'Close', + ), + const Spacer(), + Text('Share workout', + style: AppText.label + .copyWith(color: AppColors.onNight)), + const Spacer(), + const SizedBox(width: 48), // balances the close button + ], + ), + ), + Expanded( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: Sp.x4), + child: RepaintBoundary( + key: _captureKey, + child: WorkoutShareCard( + data: widget.data, + format: _format, + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(Sp.x5, 0, Sp.x5, Sp.x5), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _FormatToggle( + value: _format, + onChanged: (f) => setState(() => _format = f), + ), + const SizedBox(height: Sp.x4), + SizedBox( + width: double.infinity, + height: 54, + child: FilledButton.icon( + onPressed: _sharing ? null : _share, + icon: _sharing + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.ios_share_rounded, size: 19), + label: Text(_sharing ? 'Preparing…' : 'Share'), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _FormatToggle extends StatelessWidget { + final ShareFormat value; + final ValueChanged onChanged; + const _FormatToggle({required this.value, required this.onChanged}); + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(R.pill), + border: Border.all(color: Colors.white12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final f in ShareFormat.values) + GestureDetector( + onTap: () => onChanged(f), + behavior: HitTestBehavior.opaque, + child: AnimatedContainer( + duration: Motion.fast, + curve: Motion.curve, + padding: const EdgeInsets.symmetric( + horizontal: Sp.x5, vertical: 8), + decoration: BoxDecoration( + color: f == value + ? Colors.white.withValues(alpha: 0.14) + : Colors.transparent, + borderRadius: BorderRadius.circular(R.pill), + ), + child: Text( + f.label, + style: AppText.caption.copyWith( + color: f == value + ? AppColors.onNight + : AppColors.onNightSoft, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + ], + ), + ); +} + +/// THE one place a share composition is built. +/// +/// Both entry points — the finish screen right after a workout, and the detail +/// screen for any past workout — go through here, so the card can never say +/// one thing in one place and something else in the other. Everything the card +/// renders is decided here; the card itself just lays out what it's handed. +/// +/// The rule it encodes: **distance leads when there is a route**, because the +/// map is what the image is showing and the headline should name it. With no +/// route the clock leads instead, and the supporting stats swap to the ones +/// that still mean something indoors. +WorkoutShareData buildWorkoutShareData({ + required UnitsController units, + required String type, + required Duration duration, + required DateTime when, + required int maxHr, + required double strain, + required int calories, + WorkoutRoute? route, + int? avgHr, +}) { + final hasRoute = route != null && route.hasPath; + final title = type.isEmpty + ? 'Workout' + : type[0].toUpperCase() + type.substring(1); + + String heroValue; + String heroUnit; + List<(String, String)> stats; + if (hasRoute) { + final parts = units.distance(route.distanceMeters).split(' '); + heroValue = parts.first; + heroUnit = parts.length > 1 ? parts.sublist(1).join(' ') : ''; + stats = [ + (_shareDuration(duration), 'Time'), + // Moving pace, like everywhere else — see the note in _GpsControlPanel. + (units.pace(route.distanceMeters, route.movingSec), 'Pace'), + (strain.toStringAsFixed(1), 'Strain'), + ]; + } else { + heroValue = _shareDuration(duration); + heroUnit = ''; + stats = [ + (strain.toStringAsFixed(1), 'Strain'), + ('$calories', 'Kcal'), + (avgHr != null && avgHr > 0 ? '$avgHr' : '—', 'Avg bpm'), + ]; + } + + return WorkoutShareData( + title: title, + subtitle: _shareDate(when), + vertices: hasRoute + ? rmath.buildVertices(route.points, route.hr, maxHr) + : const [], + heroValue: heroValue, + heroUnit: heroUnit, + stats: stats, + accent: AppColors.coral, + ); +} + +String _shareDuration(Duration d) { + final h = d.inHours; + final m = d.inMinutes % 60; + final s = d.inSeconds % 60; + if (h > 0) return '${h}h ${m.toString().padLeft(2, '0')}m'; + return '${m}m ${s.toString().padLeft(2, '0')}s'; +} + +const _shareMonths = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', // + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', +]; + +String _shareDate(DateTime t) => + '${t.day} ${_shareMonths[t.month - 1]} ${t.year}'; diff --git a/lib/ui/design/design.dart b/lib/ui/design/design.dart index 9f89ba40..b476e5be 100644 --- a/lib/ui/design/design.dart +++ b/lib/ui/design/design.dart @@ -26,7 +26,6 @@ export 'motion.dart'; export 'nav_pill.dart'; export 'orbit_score.dart'; export 'pressable.dart'; -export 'radial_heatmap.dart'; export 'recap_card.dart'; export 'ring_week.dart'; export 'rows.dart'; diff --git a/lib/ui/design/gallery_screen.dart b/lib/ui/design/gallery_screen.dart index faf2a2ce..860d9980 100644 --- a/lib/ui/design/gallery_screen.dart +++ b/lib/ui/design/gallery_screen.dart @@ -9,8 +9,11 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../gps/route_math.dart' as rmath; +import '../../gps/route_models.dart' show RouteVertex, WorkoutRoute; import '../../theme/theme_controller.dart'; import '../../theme/theme_switcher.dart'; +import '../../state/units_controller.dart'; +import '../activity/workout_share_card.dart'; import '../activity/live_session_screen.dart' show GpsLiveMapView, WorkoutFinishScreen, WorkoutFinishSnapshot; import 'design.dart'; @@ -28,6 +31,9 @@ class _DesignGalleryScreenState extends State { int _nav = 0; int _chip = 0; + static const _tags = ['Caffeine', 'Alcohol', 'Late meal', 'Travel']; + final _tagsOn = {1}; + static const _spark = [ 62, 58, @@ -45,6 +51,75 @@ class _DesignGalleryScreenState extends State { 51, ]; + // The fake route and its vertices are built ONCE. `fakeRunRoute()` generates + // 140 points and `buildVertices` is O(n) with a binary search and a haversine + // per point — cheap individually, but the gallery rebuilds on every theme + // toggle and this is the exact per-build recomputation that made the finish + // screen janky. Only the formatted strings below depend on units, and those + // are just string formatting. + late final WorkoutRoute _shareRoute = fakeRunRoute(); + late final List _shareVertices = + rmath.buildVertices(_shareRoute.points, _shareRoute.hr, 190); + + /// Build the share composition from the fake run, exactly the way the finish + /// screen does — same units controller, same three stats — so what the + /// gallery shows is what ships, not a hand-written mock that can drift. + WorkoutShareData _fakeShareData(BuildContext context) { + final units = context.watch(); + final route = _shareRoute; + final parts = units.distance(route.distanceMeters).split(' '); + return WorkoutShareData( + title: 'Morning Run', + subtitle: '27 Jul 2026', + vertices: _shareVertices, + heroValue: parts.first, + heroUnit: parts.length > 1 ? parts.sublist(1).join(' ') : '', + stats: [ + ('20:06', 'Time'), + (units.pace(route.distanceMeters, route.movingSec), 'Pace'), + ('11.6', 'Strain'), + ], + accent: AppColors.coral, + ); + } + + /// Both formats side by side, scaled to fit the gallery column. Scaled — not + /// re-laid-out at a smaller width — so the proportions and type hierarchy are + /// exactly what a real post gets. + Widget _shareCardDemo(BuildContext context) { + final data = _fakeShareData(context); + Widget shrunk(ShareFormat f) => Expanded( + child: Column( + children: [ + GestureDetector( + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => WorkoutSharePreviewScreen(data: data), + ), + ), + child: FittedBox( + fit: BoxFit.contain, + child: WorkoutShareCard(data: data, format: f), + ), + ), + const SizedBox(height: Sp.x2), + Text( + '${f.label} · ${f == ShareFormat.feed ? '4:5' : '9:16'}', + style: AppText.captionMuted, + ), + ], + ), + ); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + shrunk(ShareFormat.feed), + const SizedBox(width: Sp.x4), + shrunk(ShareFormat.story), + ], + ); + } + void _toggleTheme(ThemeController ctrl) { final next = ctrl.isDark ? AppThemeChoice.light : AppThemeChoice.dark; final overlay = themeSwitchKey.currentState; @@ -198,39 +273,11 @@ class _DesignGalleryScreenState extends State { OrbitScore( score: 82, label: 'Readiness', - word: 'Primed', + word: 'Push', + wordIcon: OsIcon.intensity, color: AppColors.scoreColor(0.82), + glow: true, onTap: () {}, - satellites: [ - OrbitSatellite( - icon: OsIcon.sleep, - label: 'Sleep', - value: '7h 42m', - color: DomainAccent.sleep, - onTap: () {}, - ), - OrbitSatellite( - icon: OsIcon.heart, - label: 'Heart', - value: '48 ms', - color: DomainAccent.heart, - onTap: () {}, - ), - OrbitSatellite( - icon: OsIcon.bodyStrain, - label: 'Strain', - value: '12.4', - color: DomainAccent.strain, - onTap: () {}, - ), - OrbitSatellite( - icon: OsIcon.stress, - label: 'Stress', - value: '34', - color: DomainAccent.stress, - onTap: () {}, - ), - ], ), const SizedBox(height: Sp.x6), @@ -363,29 +410,6 @@ class _DesignGalleryScreenState extends State { ), const SizedBox(height: Sp.x6), - // ── RadialHeatmap ───────────────────────────────────────────── - const SectionHeader('RadialHeatmap'), - SurfaceCard( - child: Column( - children: [ - Text('STRAIN BY HOUR', style: AppText.overline), - const SizedBox(height: Sp.x3), - RadialHeatmap( - values: const [ - 0.05, 0.02, 0.0, null, 0.0, 0.1, 0.35, 0.8, - 0.95, 0.6, 0.3, 0.4, 0.5, 0.3, 0.2, 0.25, - 0.45, 0.85, 0.7, 0.4, 0.2, 0.1, 0.05, 0.02, - ], - color: DomainAccent.strain, - size: 190, - labels: const ['12a', '6a', '12p', '6p'], - startAngle: -1.5707963267948966, - ), - ], - ), - ), - const SizedBox(height: Sp.x6), - // ── RingWeek ────────────────────────────────────────────────── const SectionHeader('RingWeek'), SurfaceCard( @@ -397,33 +421,66 @@ class _DesignGalleryScreenState extends State { ), const SizedBox(height: Sp.x6), - // ── StateChips ──────────────────────────────────────────────── - const SectionHeader('StateChips'), - StateChips( - chips: const [ - StateChip('Energize', emoji: '⚡'), - StateChip('Recover', emoji: '🛌'), - StateChip('Focus', emoji: '🎯'), - StateChip('Calm', emoji: '🫧'), - StateChip('Push', emoji: '🔥'), + // ── StateChipView + ToggleChip ──────────────────────────────── + const SectionHeader('StateChipView · ToggleChip'), + // Display-only, accent-tinted — the exact pill the Today readiness + // ring puts under the score, at each of the three bands. + Row( + children: [ + for (final (w, i, t) in const [ + ('Push', OsIcon.intensity, 0.82), + ('Focus', OsIcon.activity, 0.55), + ('Recover', OsIcon.calm, 0.28), + ]) ...[ + StateChipView( + StateChip(w, icon: i), + selected: true, + accent: AppColors.scoreColor(t), + dense: true, + ), + const SizedBox(width: Sp.x2), + ], ], - selected: _chip, - onSelect: (i) => setState(() => _chip = i), ), - const SizedBox(height: Sp.x6), - - // ── RecapCard + MedalCard ───────────────────────────────────── - const SectionHeader('RecapCard · MedalCard'), - RecapCard( - title: 'Weekly recap', - highlight: 'You slept 40 min more than your usual this week.', - value: '7h 12m', - caption: 'daily average', - bars: const [6.2, 7.5, 8.1, 6.9, 7.2, 8.4, 7.1], - accent: DomainAccent.sleep, - onTap: () {}, + const SizedBox(height: Sp.x3), + // Interactive variant (onTap) — a Wrap of chips, single-select. + Wrap( + spacing: Sp.x2, + runSpacing: Sp.x2, + children: [ + for (final (i, c) in const [ + (0, StateChip('Push', icon: OsIcon.intensity)), + (1, StateChip('Focus', icon: OsIcon.activity)), + (2, StateChip('Recover', icon: OsIcon.calm)), + (3, StateChip('Sleep', icon: OsIcon.sleep)), + ]) + StateChipView( + c, + selected: _chip == i, + onTap: () => setState(() => _chip = i), + ), + ], ), const SizedBox(height: Sp.x3), + // ToggleChip — the multi-select sibling (journal tags, cycle symptoms). + Wrap( + spacing: Sp.x2, + runSpacing: Sp.x2, + children: [ + for (var i = 0; i < _tags.length; i++) + ToggleChip( + _tags[i], + selected: _tagsOn.contains(i), + onTap: () => setState( + () => _tagsOn.contains(i) ? _tagsOn.remove(i) : _tagsOn.add(i), + ), + ), + ], + ), + const SizedBox(height: Sp.x6), + + // ── MedalCard ───────────────────────────────────────────────── + const SectionHeader('MedalCard'), MedalCard( medal: '5K', overline: 'Personal record', @@ -789,6 +846,21 @@ class _DesignGalleryScreenState extends State { ), const SizedBox(height: Sp.x6), + // ── Share card ──────────────────────────────────────────────── + const SectionHeader('Share card'), + const SizedBox(height: Sp.x2), + Text( + 'What actually gets posted — composed for a feed, not a capture of ' + 'the finish screen. Map full-bleed, one headline figure, three ' + 'supporting stats, nothing else. Both cards below are the REAL ' + 'widget with the fake run\'s data and your current units; tap either ' + 'to open the live preview screen with its format switcher.', + style: AppText.captionMuted, + ), + const SizedBox(height: Sp.x4), + _shareCardDemo(context), + const SizedBox(height: Sp.x6), + // ── Nav pill ────────────────────────────────────────────────── // Mirrors the shipped shell: five even tabs, no center action. const SectionHeader('FloatingNavPill'), diff --git a/lib/ui/design/nav_pill.dart b/lib/ui/design/nav_pill.dart index 756361f1..856a970b 100644 --- a/lib/ui/design/nav_pill.dart +++ b/lib/ui/design/nav_pill.dart @@ -12,7 +12,6 @@ import 'package:flutter/services.dart'; import '../../theme/theme.dart'; import '../../theme/tokens.dart'; import '../kit/os_icons.dart'; -import 'pressable.dart'; class NavPillItem { /// Illustrated tab icon (full-colour, theme-aware). Always rendered at full @@ -130,51 +129,6 @@ class FloatingNavPill extends StatelessWidget { } } -/// The standard ember circle for [FloatingNavPill.centerAction] — press -/// feedback + haptic + semantics come free. Kept public for custom shells; -/// the app shell itself no longer renders a center action. -class NavPillAction extends StatelessWidget { - /// Glyph shown on the ember circle (e.g. [OsIcon.add]). - final OsIcon? icon; - - final VoidCallback onTap; - final String semanticLabel; - - const NavPillAction({ - super.key, - this.icon, - required this.onTap, - this.semanticLabel = 'Start', - }) : assert(icon != null); - - @override - Widget build(BuildContext context) { - return Semantics( - button: true, - label: semanticLabel, - child: Pressable( - // Pressable fires the selection haptic itself. - pressedScale: 0.9, - onTap: onTap, - // The old illustrated art was itself a rendered "soft-3D button - // coin" and needed no circle behind it. Plain vector glyphs do — - // draw the ember circle explicitly, same 46px footprint as before. - child: Container( - width: 46, - height: 46, - decoration: BoxDecoration( - color: AppColors.accent, - shape: BoxShape.circle, - ), - child: Center( - child: OsAppIcon(icon!, size: 22, color: Colors.white), - ), - ), - ), - ); - } -} - /// Internal: keeps item hit-targets comfortable inside the tight pill. class _NavPad extends StatelessWidget { final Widget child; diff --git a/lib/ui/design/orbit_score.dart b/lib/ui/design/orbit_score.dart index ae282b88..28396e1d 100644 --- a/lib/ui/design/orbit_score.dart +++ b/lib/ui/design/orbit_score.dart @@ -1,18 +1,18 @@ -// OrbitScore — the whole-health hero: one radial score with the health -// domains orbiting it as tappable satellite chips (the image-4 pattern). -// The center carries the big number + status word; faint concentric orbit -// rings give structure; each satellite sits on the outer orbit and routes to -// its domain screen. Restrained by design: hairline rings, no glow, no -// particles — presence comes from scale and composition. +// OrbitScore — the whole-health hero: one radial score, and nothing else +// competing with it. The center carries the label, the big number, and the +// status chip. Restrained by design: no glow by default, no particles — +// presence comes from scale and negative space. +// +// It used to float up-to-four tappable domain "satellites" on concentric +// orbits around the core. Those were cut from the Today hero (that data +// already lives one tap away on its own tab, and at near-equal visual weight +// they fought the score), and with no caller left the whole orbit/satellite +// layer went with them. // // OrbitScore( // score: 82, // null → honest baseline/empty center -// word: 'Primed', +// word: 'Push', wordIcon: OsIcon.intensity, // rendered as a state chip // color: AppColors.scoreColor(0.82), -// satellites: [ -// OrbitSatellite(icon: OsIcon.sleep, label: 'Sleep', onTap: …), -// …up to 4, rendered at staggered orbit anchors… -// ], // ) import 'dart:math' as math; @@ -21,38 +21,24 @@ import 'package:flutter/material.dart'; import '../../theme/theme.dart'; import '../../theme/tokens.dart'; -import '../kit/kit.dart' show OsAppIcon, OsIcon; +import '../kit/kit.dart' show OsIcon; import 'arc_gauge.dart'; import 'motion.dart'; import 'pressable.dart'; - -class OrbitSatellite { - final OsIcon icon; - - /// Optional illustrated icon — replaces the tinted [icon] glyph when the - /// domain has full-colour art (rendered as-is, never tinted). - final String label; - - /// Optional tiny value shown after the label ('48 ms'). - final String? value; - final Color? color; - final VoidCallback? onTap; - const OrbitSatellite({ - required this.icon, - required this.label, - this.value, - this.color, - this.onTap, - }); -} +import 'state_chips.dart'; class OrbitScore extends StatelessWidget { /// 0–100 score. Null renders [center] (the honest building/empty state). final int? score; - /// Status word under the number ('Primed', 'Steady', 'Run easy'). + /// Status word under the number ('Push', 'Focus', 'Recover'). Rendered as a + /// calm [StateChipView] pill tinted with [color], so the ring's verdict + /// reads as a state you're *in* rather than a loose caption. final String? word; + /// Optional glyph for the [word] chip. Null renders the word alone. + final OsIcon? wordIcon; + /// Whispered overline above the number ('READINESS'). final String? label; @@ -70,9 +56,6 @@ class OrbitScore extends StatelessWidget { /// (2 of 5 nights = 0.4) while the honest center explains it. final double? ringFill; - /// Up to four satellites, anchored NE / SE / SW / NW around the orbit. - final List satellites; - /// Tap on the score core itself. final VoidCallback? onTap; @@ -86,12 +69,12 @@ class OrbitScore extends StatelessWidget { super.key, required this.score, this.word, + this.wordIcon, this.label, this.color, this.confidence = 1.0, this.center, this.ringFill, - this.satellites = const [], this.onTap, this.height = 280, this.glow = false, @@ -100,7 +83,6 @@ class OrbitScore extends StatelessWidget { @override Widget build(BuildContext context) { final c = color ?? AppColors.accent; - final hasSatellites = satellites.isNotEmpty; final reduceMotion = MediaQuery.maybeDisableAnimationsOf(context) ?? false; return SizedBox( @@ -109,16 +91,9 @@ class OrbitScore extends StatelessWidget { builder: (context, box) { final w = box.maxWidth; final side = math.min(w, height); - // Core gauge ≈ half the shorter side when satellites orbit it; - // with no satellites to make room for, the ring itself is the - // whole composition — let it fill far more of the space and give - // it generous surrounding negative space instead of chips. - final coreSize = hasSatellites - ? (side * 0.52).clamp(120.0, 168.0) - : (side * 0.72).clamp(160.0, 224.0); - final orbitR = coreSize / 2 + side * 0.16; - - final coreCenter = Offset(w / 2, height / 2); + // The ring IS the composition — let it fill most of the shorter + // side and give it generous surrounding negative space. + final coreSize = (side * 0.72).clamp(160.0, 224.0); Widget core = ArcGauge( value: score == null @@ -126,7 +101,7 @@ class OrbitScore extends StatelessWidget { : (score! / 100).clamp(0.0, 1.0), color: c, size: coreSize, - stroke: hasSatellites ? 10 : 13, + stroke: 13, sweepFraction: 0.78, confidence: confidence, glow: glow, @@ -154,14 +129,15 @@ class OrbitScore extends StatelessWidget { color: score == null ? AppColors.inkMuted : null, ), ), - if (word != null) - Text( - word!, - style: AppText.caption.copyWith( - color: c, - fontWeight: FontWeight.w800, - ), + if (word != null) ...[ + const SizedBox(height: Sp.x1), + StateChipView( + StateChip(word!, icon: wordIcon), + selected: true, + accent: c, + dense: true, ), + ], ], ), ); @@ -185,156 +161,12 @@ class OrbitScore extends StatelessWidget { ); } - return Stack( - clipBehavior: Clip.none, - children: [ - // Faint concentric orbits (hairline; structure, not decoration) - // — only drawn when satellites actually anchor to them; with - // no satellites the ring is the whole composition and gets - // pure negative space instead of rings around nothing. - if (hasSatellites) - Positioned.fill( - child: RepaintBoundary( - child: CustomPaint( - painter: _OrbitRingsPainter( - center: coreCenter, - radii: [orbitR * 0.82, orbitR], - color: AppColors.inkMuted.withValues( - alpha: AppColors.isDark ? 0.22 : 0.28, - ), - ), - ), - ), - ), - Positioned( - left: coreCenter.dx - coreSize / 2, - top: coreCenter.dy - coreSize / 2, - child: animatedCore.dsEnter(), - ), - ..._placeSatellites(w, coreCenter, orbitR), - ], - ); + return Center(child: animatedCore.dsEnter()); }, ), ); } - /// Anchor the (up to 4) satellites at staggered angles on the outer orbit, - /// clamped into the box so chips never overflow the screen edge. - List _placeSatellites(double w, Offset c, double r) { - // NE, SW, SE, NW — alternating sides reads balanced with any count. - const angles = [-0.30 * math.pi, 0.72 * math.pi, 0.28 * math.pi, -0.72 * math.pi]; - final out = []; - for (var i = 0; i < satellites.length && i < 4; i++) { - final s = satellites[i]; - final ang = angles[i]; - final p = c + Offset(math.cos(ang), math.sin(ang)) * r; - out.add( - Positioned( - left: p.dx < w / 2 ? math.max(0, p.dx - 76) : null, - right: p.dx >= w / 2 ? math.max(0, w - p.dx - 76) : null, - // Half the chip height (6+6 padding + 34 icon = 46) keeps the pill - // vertically centred on its orbit anchor. - top: p.dy - 23, - child: _SatelliteChip(s).dsEnter(index: i + 2), - ), - ); - } - return out; - } -} - -class _SatelliteChip extends StatelessWidget { - final OrbitSatellite s; - const _SatelliteChip(this.s); - - @override - Widget build(BuildContext context) { - return Pressable( - pressedScale: 0.92, - onTap: s.onTap, - child: Container( - constraints: const BoxConstraints(maxWidth: 152), - padding: const EdgeInsets.symmetric(horizontal: Sp.x3, vertical: 6), - decoration: BoxDecoration( - color: Elevation.surfaceAt(2), - borderRadius: BorderRadius.circular(R.pill), - border: Elevation.border(2), - boxShadow: Elevation.shadows(1), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // The illustrations carry built-in transparent padding, so they - // need a larger canvas (34) than the 28px glyph disc to read at - // the same visual weight inside the pill. - OsAppIcon(s.icon, size: 34), - const SizedBox(width: Sp.x2), - Flexible( - child: Text( - s.label, - style: AppText.caption.copyWith( - color: AppColors.ink, - fontWeight: FontWeight.w800, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (s.value != null) ...[ - const SizedBox(width: Sp.x1 + 2), - Text( - s.value!, - style: AppText.caption.copyWith(color: AppColors.inkSoft), - maxLines: 1, - ), - ], - ], - ), - ), - ); - } } -class _OrbitRingsPainter extends CustomPainter { - final Offset center; - final List radii; - final Color color; - _OrbitRingsPainter({ - required this.center, - required this.radii, - required this.color, - }); - @override - void paint(Canvas canvas, Size size) { - final p = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = 1 - ..color = color; - for (final r in radii) { - canvas.drawCircle(center, r, p); - } - // Four quiet anchor ticks on the outer orbit (N/E/S/W) — a compass, not - // decoration; they make the orbit read as a measured instrument. - final tick = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = 2 - ..strokeCap = StrokeCap.round - ..color = color; - final r = radii.last; - for (var k = 0; k < 4; k++) { - final a = k * math.pi / 2; - final dir = Offset(math.cos(a), math.sin(a)); - canvas.drawLine( - center + dir * (r - 3), - center + dir * (r + 3), - tick, - ); - } - } - - @override - bool shouldRepaint(_OrbitRingsPainter old) => - old.center != center || old.color != color || old.radii != radii; -} diff --git a/lib/ui/design/radial_heatmap.dart b/lib/ui/design/radial_heatmap.dart deleted file mode 100644 index b5ce5e7c..00000000 --- a/lib/ui/design/radial_heatmap.dart +++ /dev/null @@ -1,164 +0,0 @@ -// RadialHeatmap — the radial segmented heatmap from the refs' muscle map: a -// disc of sectors × rings where each sector is a category (muscle group, -// hour-of-day, domain) and fill intensity encodes 0..1 load. Meaningful, not -// decorative: sectors with no data stay honest track-grey, and the strongest -// sector can carry a label callout. -// -// RadialHeatmap( -// values: strainByHour, // one 0..1 (or null) per sector -// rings: 3, // intensity quantized across rings -// color: DomainAccent.strain, -// labels: ['12a', '6a', '12p', '6p'], // quiet compass labels (optional) -// ) - -import 'dart:math' as math; - -import 'package:flutter/material.dart'; - -import '../../theme/theme.dart'; -import '../../theme/tokens.dart'; - -class RadialHeatmap extends StatelessWidget { - /// One intensity per sector, 0..1; null = no data (honest empty sector). - final List values; - - /// Concentric intensity rings (inner fills first — like the refs' map). - final int rings; - - final Color? color; - final double size; - - /// Quiet labels. Pass exactly one per sector to label every sector at its - /// own mid-angle (e.g. seven weekday names); any other count falls back to - /// up to four compass labels at N/E/S/W. - final List? labels; - - /// Start angle of sector 0 (default: 12 o'clock). - final double startAngle; - - const RadialHeatmap({ - super.key, - required this.values, - this.rings = 3, - this.color, - this.size = 168, - this.labels, - this.startAngle = -math.pi / 2, - }); - - @override - Widget build(BuildContext context) { - final c = color ?? AppColors.accent; - return RepaintBoundary( - child: SizedBox( - width: size, - height: size, - child: TweenAnimationBuilder( - duration: Motion.ring, - curve: Motion.emphatic, - tween: Tween(begin: 0, end: 1), - builder: (_, t, _) => CustomPaint( - painter: _RadialHeatmapPainter( - values: values, - rings: rings.clamp(1, 6), - color: c, - track: AppColors.surfaceAlt, - labelColor: AppColors.inkMuted, - labelStyle: AppText.captionMuted.copyWith(fontSize: 9), - labels: labels, - startAngle: startAngle, - reveal: t, - ), - ), - ), - ), - ); - } -} - -class _RadialHeatmapPainter extends CustomPainter { - final List values; - final int rings; - final Color color; - final Color track; - final Color labelColor; - final TextStyle labelStyle; - final List? labels; - final double startAngle; - final double reveal; - - _RadialHeatmapPainter({ - required this.values, - required this.rings, - required this.color, - required this.track, - required this.labelColor, - required this.labelStyle, - required this.labels, - required this.startAngle, - required this.reveal, - }); - - @override - void paint(Canvas canvas, Size size) { - if (values.isEmpty) return; - final c = size.center(Offset.zero); - final outerR = size.shortestSide / 2 - (labels == null ? 2 : 12); - final innerR = outerR * 0.30; - final ringW = (outerR - innerR) / rings; - final n = values.length; - final sweep = 2 * math.pi / n; - const gap = 0.035; // radians between sectors - - final paintSeg = Paint()..style = PaintingStyle.stroke; - - for (var i = 0; i < n; i++) { - final v = values[i]; - final a0 = startAngle + sweep * i + gap / 2; - final sw = sweep - gap; - final level = v == null ? 0 : (v.clamp(0.0, 1.0) * rings * reveal); - for (var r = 0; r < rings; r++) { - final radius = innerR + ringW * r + ringW / 2; - paintSeg.strokeWidth = ringW - 2.5; - // Ring r is "on" when intensity reaches it; partial top ring fades in. - final fill = (level - r).clamp(0.0, 1.0); - paintSeg.color = fill <= 0 - ? track - : Color.lerp(track, color, 0.25 + 0.75 * fill)!; - canvas.drawArc( - Rect.fromCircle(center: c, radius: radius), - a0, - sw, - false, - paintSeg, - ); - } - } - - // Quiet labels: one per sector (drawn at its mid-angle) when the counts - // match, else the classic ≤4 compass labels at N/E/S/W. - final ls = labels; - if (ls != null && ls.isNotEmpty) { - final perSector = ls.length == n; - final count = perSector ? n : math.min(ls.length, 4); - for (var k = 0; k < count; k++) { - final a = perSector - ? startAngle + sweep * (k + 0.5) - : startAngle + (2 * math.pi / math.min(ls.length, 4)) * k; - final p = c + Offset(math.cos(a), math.sin(a)) * (outerR + 7); - final tp = TextPainter( - text: TextSpan(text: ls[k], style: labelStyle), - textDirection: TextDirection.ltr, - )..layout(); - tp.paint(canvas, p - Offset(tp.width / 2, tp.height / 2)); - } - } - } - - @override - bool shouldRepaint(_RadialHeatmapPainter old) => - old.values != values || - old.color != color || - old.reveal != reveal || - old.rings != rings; -} diff --git a/lib/ui/design/recap_card.dart b/lib/ui/design/recap_card.dart index 3f4e5555..a94c763e 100644 --- a/lib/ui/design/recap_card.dart +++ b/lib/ui/design/recap_card.dart @@ -1,131 +1,16 @@ -// RecapCard + MedalCard — the "weekly recap" and "achievement medal" -// compositions from the refs. +// MedalCard — an inverted (ink) achievement card with an engraved medal disc: +// personal records, streak milestones. Restrained metal, no confetti. // -// • [RecapCard] — a headline period recap: title, one highlight sentence in -// a soft banner, a big average figure, and a quiet bar strip of the week. -// The whole card taps through to the full recap screen. -// • [MedalCard] — an inverted (ink) achievement card with an engraved medal -// disc: personal records, streak milestones. Restrained metal, no confetti. +// This file also held RecapCard (a headline period recap with a bar strip). +// The recap screen builds its own composition, so RecapCard never had a call +// site outside the gallery and was removed. import 'package:flutter/material.dart'; import '../../theme/theme.dart'; import '../../theme/tokens.dart'; -import '../kit/charts.dart' show MiniBars; import '../kit/kit.dart' show AppIcon, OsIcon; import 'bento.dart'; -import 'big_stat.dart'; - -class RecapCard extends StatelessWidget { - /// 'Weekly recap', 'January'… - final String title; - - /// One highlight sentence ('You slept 40 min more than usual'). - final String? highlight; - - /// The headline figure ('7h 12m', '11 840'). - final String? value; - final String? unit; - - /// Label under the value ('daily average'). - final String? caption; - - /// A small bar strip (e.g. 7 daily values; nulls = gaps). - final List? bars; - - final Color? accent; - final VoidCallback? onTap; - - const RecapCard({ - super.key, - required this.title, - this.highlight, - this.value, - this.unit, - this.caption, - this.bars, - this.accent, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - final a = accent ?? AppColors.accent; - // Nulls go THROUGH to MiniBars, which keeps their slots empty. Stripping - // them here compacted the strip: a week missing Wednesday drew six bars - // with Thu–Sun shifted a day left, silently re-dating every value after - // the gap. - final barStrip = bars ?? const []; - return BentoTile( - tone: BentoTone.paper, - accent: a, - padding: const EdgeInsets.all(Sp.x4), - onTap: onTap, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - TileHeader( - title, - trailing: onTap == null - ? null - : AppIcon(OsIcon.arrowRight, size: 14, color: AppColors.inkMuted), - ), - if (highlight != null) ...[ - const SizedBox(height: Sp.x3), - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: Sp.x3, - vertical: Sp.x2 + 2, - ), - decoration: BoxDecoration( - color: a.withValues(alpha: AppColors.isDark ? 0.16 : 0.10), - borderRadius: BorderRadius.circular(R.chip), - ), - child: Text( - highlight!, - style: AppText.caption.copyWith( - color: AppColors.ink, - fontWeight: FontWeight.w700, - height: 1.35, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ], - if (value != null) ...[ - const SizedBox(height: Sp.x3), - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: BigStat( - value: value, - unit: unit, - caption: caption, - size: BigStatSize.md, - ), - ), - // Gate on how many slots actually CARRY a value (a strip of - // one real bar plus six gaps isn't a trend), but draw the - // full-length strip so the bars keep their days. - if (barStrip.whereType().length >= 2) ...[ - const SizedBox(width: Sp.x3), - SizedBox( - width: 96, - child: MiniBars(barStrip, color: a, height: 34), - ), - ], - ], - ), - ], - ], - ), - ); - } -} /// An inverted achievement card with an engraved medal disc. class MedalCard extends StatelessWidget { diff --git a/lib/ui/design/state_chips.dart b/lib/ui/design/state_chips.dart index b7e062bb..5b20fe04 100644 --- a/lib/ui/design/state_chips.dart +++ b/lib/ui/design/state_chips.dart @@ -1,13 +1,18 @@ -// StateChips — the mood/state chip row from the refs: a horizontally -// scrollable set of pill chips (emoji or icon + word), single-select, calm. -// Used for journal moods, coach intents ('Energize', 'Recover', 'Focus'), -// filter rows. Selection is a soft accent fill — never a colour explosion. +// State chips — the calm pill vocabulary: emoji-or-icon + word, soft accent +// fill when on, never a colour explosion. // -// StateChips( -// chips: [StateChip('Energize', emoji: '⚡'), StateChip('Recover', …)], -// selected: 1, // null = nothing selected -// onSelect: (i) => …, -// ) +// • [StateChipView] — ONE pill. Display-only (no onTap) or interactive. +// The Today readiness ring puts one under the score ('Push' / 'Focus' / +// 'Recover'), tinted with the score colour. +// • [ToggleChip] — an independent on/off pill for multi-select rows +// (journal tags, cycle symptoms, notification kinds), painted from +// [StateChipView]'s tokens so both stay one look. +// +// There used to be a single-select `StateChips` row here too. Nothing in the +// app single-selects a chip row (journal/cycle/profile all multi-select via +// ToggleChip), so it was removed rather than kept as gallery-only scaffolding +// — a Wrap of [StateChipView]s is the same thing in six lines if it's ever +// wanted back. import 'package:flutter/material.dart'; import '../kit/os_icons.dart'; @@ -24,35 +29,49 @@ class StateChip { const StateChip(this.label, {this.emoji, this.icon}); } -/// ToggleChip — the multi-select sibling of [StateChips]: one independent -/// on/off pill (journal tags, cycle symptoms). Soft accent fill + tinted -/// hairline when on; calm surface otherwise. Never a colour explosion. -class ToggleChip extends StatelessWidget { - final String label; +/// Soft fill for a chip in its ON state. With no [accent] this is the brand +/// accent-soft token; with one, the accent is blended into the surface so a +/// domain- or score-tinted chip reads as the same material, not as a second +/// colour system. +Color _chipFill(Color? accent) => accent == null + ? AppColors.accentSoft + : Color.alphaBlend( + accent.withValues(alpha: AppColors.isDark ? 0.18 : 0.13), + Elevation.surfaceAt(1), + ); + +/// Ink for a chip in its ON state — the readable on-accent token by default, +/// the accent itself when the caller tinted the chip. +Color _chipInk(Color? accent) => accent ?? AppColors.onAccentSoft; + +/// StateChipView — ONE calm pill: icon/emoji + word, soft accent fill when on. +/// +/// The single shared renderer behind every chip in the system, [ToggleChip] +/// included. Pass [onTap] for an interactive chip; leave it null for a +/// display-only badge (the Today readiness ring's status word). +class StateChipView extends StatelessWidget { + final StateChip chip; final bool selected; + final Color? accent; final VoidCallback? onTap; - /// Domain accent; defaults to the brand accent (soft fill + accent ink). - final Color? accent; + /// Slightly tighter padding for chips that sit inside a constrained + /// container (e.g. within the readiness ring). + final bool dense; - const ToggleChip( - this.label, { + const StateChipView( + this.chip, { super.key, - required this.selected, - this.onTap, + this.selected = false, this.accent, + this.onTap, + this.dense = false, }); @override Widget build(BuildContext context) { final a = accent ?? AppColors.accent; - final ink = accent == null ? AppColors.onAccentSoft : a; - final fill = accent == null - ? AppColors.accentSoft - : Color.alphaBlend( - a.withValues(alpha: AppColors.isDark ? 0.18 : 0.13), - Elevation.surfaceAt(1), - ); + final ink = selected ? _chipInk(accent) : AppColors.inkSoft; return Pressable( pressedScale: 0.94, borderRadius: BorderRadius.circular(R.pill), @@ -60,98 +79,84 @@ class ToggleChip extends StatelessWidget { child: AnimatedContainer( duration: Motion.fast, curve: Motion.curve, - padding: const EdgeInsets.symmetric(horizontal: Sp.x3, vertical: Sp.x2), + padding: EdgeInsets.symmetric( + horizontal: dense ? Sp.x2 + 2 : Sp.x3 + 2, + vertical: dense ? 5 : 8, + ), decoration: BoxDecoration( - color: selected ? fill : Elevation.surfaceAt(1), + color: selected ? _chipFill(accent) : Elevation.surfaceAt(1), borderRadius: BorderRadius.circular(R.pill), border: Border.all( color: selected ? a.withValues(alpha: 0.55) : AppColors.divider, ), ), - child: Text( - label, - style: AppText.label.copyWith( - color: selected ? ink : AppColors.inkSoft, - fontWeight: FontWeight.w700, - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (chip.emoji != null) ...[ + Text(chip.emoji!, style: const TextStyle(fontSize: 13)), + const SizedBox(width: Sp.x1 + 2), + ] else if (chip.icon != null) ...[ + AppIcon(chip.icon!, size: dense ? 13 : 14, color: ink), + const SizedBox(width: Sp.x1 + 2), + ], + Text( + chip.label, + style: AppText.caption.copyWith( + fontWeight: FontWeight.w700, + color: ink, + ), + ), + ], ), ), ); } } -class StateChips extends StatelessWidget { - final List chips; - final int? selected; - final ValueChanged? onSelect; - final Color? accent; +/// ToggleChip — the multi-select pill: one independent on/off chip (journal +/// tags, cycle symptoms). Soft accent fill + tinted hairline when on; calm +/// surface otherwise. Same tokens as [StateChipView], label-only (no glyph). +class ToggleChip extends StatelessWidget { + final String label; + final bool selected; + final VoidCallback? onTap; - /// Scroll horizontally (default) or wrap to multiple lines. - final bool wrap; + /// Domain accent; defaults to the brand accent (soft fill + accent ink). + final Color? accent; - const StateChips({ + const ToggleChip( + this.label, { super.key, - required this.chips, - this.selected, - this.onSelect, + required this.selected, + this.onTap, this.accent, - this.wrap = false, }); @override Widget build(BuildContext context) { - final children = [ - for (var i = 0; i < chips.length; i++) - _chip(context, i, chips[i], i == selected), - ]; - if (wrap) { - return Wrap(spacing: Sp.x2, runSpacing: Sp.x2, children: children); - } - return SizedBox( - height: 40, - child: ListView.separated( - scrollDirection: Axis.horizontal, - physics: const BouncingScrollPhysics(), - itemCount: children.length, - separatorBuilder: (_, _) => const SizedBox(width: Sp.x2), - itemBuilder: (_, i) => Center(child: children[i]), - ), - ); - } - - Widget _chip(BuildContext context, int i, StateChip c, bool on) { final a = accent ?? AppColors.accent; return Pressable( pressedScale: 0.94, - onTap: onSelect == null ? null : () => onSelect!(i), + borderRadius: BorderRadius.circular(R.pill), + onTap: onTap, child: AnimatedContainer( duration: Motion.fast, - padding: const EdgeInsets.symmetric(horizontal: Sp.x3 + 2, vertical: 8), + curve: Motion.curve, + padding: const EdgeInsets.symmetric(horizontal: Sp.x3, vertical: Sp.x2), decoration: BoxDecoration( - color: on ? AppColors.accentSoft : Elevation.surfaceAt(1), + color: selected ? _chipFill(accent) : Elevation.surfaceAt(1), borderRadius: BorderRadius.circular(R.pill), border: Border.all( - color: on ? a.withValues(alpha: 0.55) : AppColors.divider, + color: selected ? a.withValues(alpha: 0.55) : AppColors.divider, ), ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (c.emoji != null) ...[ - Text(c.emoji!, style: const TextStyle(fontSize: 13)), - const SizedBox(width: Sp.x1 + 2), - ] else if (c.icon != null) ...[ - AppIcon(c.icon!, size: 14, color: on ? AppColors.onAccentSoft : AppColors.inkSoft), - const SizedBox(width: Sp.x1 + 2), - ], - Text( - c.label, - style: AppText.caption.copyWith( - fontWeight: FontWeight.w700, - color: on ? AppColors.onAccentSoft : AppColors.inkSoft, - ), - ), - ], + child: Text( + label, + style: AppText.label.copyWith( + color: selected ? _chipInk(accent) : AppColors.inkSoft, + fontWeight: FontWeight.w700, + ), ), ), ); diff --git a/lib/ui/kit/charts.dart b/lib/ui/kit/charts.dart index 67c36040..859fac23 100644 --- a/lib/ui/kit/charts.dart +++ b/lib/ui/kit/charts.dart @@ -1,14 +1,14 @@ -// OpenStrap chart kit — rings, sparkline bars, labeled week bars, area sparks, -// the coral dot-matrix, and the composite StatTile. All paper-on-coral styled. +// OpenStrap chart kit — rings, gauges, sparkline bars, labeled week bars, the +// time-series chart family and the workout HR/zone strips. All paper-on-coral +// styled. Every widget here has at least one live call site in the app; the +// dead ornamental ones (area spark, dot-matrix, calendar heatmap, composite +// stat tile, baseline progress) were removed rather than kept "just in case". import 'dart:math' as math; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:fl_chart/fl_chart.dart'; -import '../../models/metric.dart'; import '../../theme/theme.dart'; import '../../theme/tokens.dart'; -import 'kit.dart'; import '../design/arc_gauge.dart'; import '../design/controls.dart' show StatusChip, ChipTone; import '../design/domains.dart' show DomainAccent; @@ -87,94 +87,6 @@ class RingStat extends StatelessWidget { ); } -/// BaselineProgress — the honest "still learning you" state, rendered as a -/// partially-filled [Gauge] (nights collected / nights needed) with the count -/// remaining in the centre and a line saying what it unlocks. Replaces the bare -/// "Need N more nights" text where a baseline is still filling in. -class BaselineProgress extends StatelessWidget { - final int collected; - final int needed; - final String unlocks; // e.g. 'to unlock Readiness' - final Color? color; - final double size; - const BaselineProgress({ - super.key, - required this.collected, - required this.needed, - this.unlocks = '', - this.color, - this.size = 150, - }); - - /// Build from a baseline-gated [Metric] (`need_baseline:have=H,need=N`). - /// Returns null if the metric isn't a baseline abstention. - static BaselineProgress? fromMetric( - Metric m, { - String unlocks = '', - Color? color, - double size = 150, - Key? key, - }) { - final note = m.note; - if (note == null || !note.contains('need_baseline:')) return null; - final match = RegExp(r'have=(\d+),need=(\d+)').firstMatch(note); - if (match == null) return null; - final have = int.tryParse(match.group(1)!) ?? 0; - final need = int.tryParse(match.group(2)!) ?? 0; - if (need <= 0) return null; - return BaselineProgress( - key: key, - collected: have.clamp(0, need), - needed: need, - unlocks: unlocks, - color: color, - size: size, - ); - } - - @override - Widget build(BuildContext context) { - final c = color ?? AppColors.coral; - final remaining = (needed - collected).clamp(0, needed); - final frac = needed == 0 ? 0.0 : (collected / needed).clamp(0.0, 1.0); - final numSize = (size * 0.28).clamp(20.0, 44.0); - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Gauge( - t: frac, - color: c, - size: size, - stroke: size < 110 ? 10 : 12, - center: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text('$remaining', style: AppText.display.copyWith(fontSize: numSize)), - Text( - remaining == 1 ? 'night to go' : 'nights to go', - style: AppText.caption.copyWith(fontSize: size < 110 ? 9.5 : 12), - ), - ], - ), - ), - if (unlocks.isNotEmpty) ...[ - const SizedBox(height: Sp.x4), - Text( - unlocks, - style: AppText.bodySoft, - textAlign: TextAlign.center, - ), - const SizedBox(height: Sp.x2), - Text( - '$collected of $needed nights', - style: AppText.captionMuted, - ), - ], - ], - ); - } -} - /// Tiny sparkline bars (for inside cards). Values normalized to their own max. class MiniBars extends StatelessWidget { /// Bar values. A NULL entry is a documented gap (nothing was measured for @@ -359,62 +271,6 @@ class LabeledBars extends StatelessWidget { } } -/// Smooth area spark (HR / strain over a window) using fl_chart. -class AreaSpark extends StatelessWidget { - final List values; - final Color? color; - final double height; - const AreaSpark(this.values, {super.key, this.color, this.height = 90}); - @override - Widget build(BuildContext context) { - final color = this.color ?? AppColors.coral; - if (values.length < 2) { - return SizedBox( - height: height, - child: Center( - child: Text('Not enough data yet', style: AppText.captionMuted), - ), - ); - } - final spots = [ - for (int i = 0; i < values.length; i++) FlSpot(i.toDouble(), values[i]), - ]; - final minY = values.reduce(math.min); - final maxY = values.reduce(math.max); - return SizedBox( - height: height, - child: LineChart( - LineChartData( - minY: minY - (maxY - minY) * 0.15 - 0.5, - maxY: maxY + (maxY - minY) * 0.15 + 0.5, - gridData: const FlGridData(show: false), - titlesData: const FlTitlesData(show: false), - borderData: FlBorderData(show: false), - lineTouchData: const LineTouchData(enabled: false), - lineBarsData: [ - LineChartBarData( - spots: spots, - isCurved: true, - curveSmoothness: 0.3, - color: color, - barWidth: 3, - dotData: const FlDotData(show: false), - belowBarData: BarAreaData( - show: true, - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [color.withValues(alpha: 0.28), Colors.transparent], - ), - ), - ), - ], - ), - ), - ); - } -} - class TimeSeriesPoint { final double x; final double y; @@ -1158,63 +1014,6 @@ class ZoneTimelineBar extends StatelessWidget { } } -/// Coral dot-matrix column chart (ref #2 Stats). Each column is a stack of -/// rounded squares; filled count ∝ value. Great for week/month step-like data. -class DotMatrix extends StatelessWidget { - final List values; - final int rows; - final Color? color; - final double cell; - const DotMatrix( - this.values, { - super.key, - this.rows = 12, - this.color, - this.cell = 12, - }); - @override - Widget build(BuildContext context) { - final color = this.color ?? AppColors.coral; - if (values.isEmpty) return const SizedBox.shrink(); - final maxV = math.max(1.0, values.reduce(math.max)); - return LayoutBuilder( - builder: (context, c) { - return SizedBox( - height: rows * (cell + 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - for (final v in values) - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - for (int r = rows - 1; r >= 0; r--) - Padding( - padding: const EdgeInsets.all(2), - child: Container( - height: cell, - decoration: BoxDecoration( - color: ((v / maxV) * rows) > r - ? color.withValues( - alpha: 0.45 + 0.55 * (r / rows), - ) - : color.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(4), - ), - ), - ), - ], - ), - ), - ], - ), - ); - }, - ); - } -} - /// Horizontal multi-segment bar (HR zones z1..z5). class SegmentBar extends StatelessWidget { final List values; @@ -1255,150 +1054,6 @@ class SegmentBar extends StatelessWidget { } } -/// Composite stat tile: icon + label, big number + unit, optional delta + spark. -/// Renders "—" muted when [value] is null. Confidence dot + honesty tag optional. -class StatTile extends StatelessWidget { - final OsIcon icon; - final String label; - final String? value; - final String? unit; - final num? deltaPct; - final bool deltaGoodIsUp; - final List? spark; - final Color? accent; - final double? confidence; - final Widget? tag; - final VoidCallback? onTap; - const StatTile({ - super.key, - required this.icon, - required this.label, - required this.value, - this.unit, - this.deltaPct, - this.deltaGoodIsUp = true, - this.spark, - this.accent, - this.confidence, - this.tag, - this.onTap, - }); - @override - Widget build(BuildContext context) { - final accent = this.accent ?? AppColors.coral; - return ConstrainedBox( - constraints: const BoxConstraints(minHeight: 110), - child: ProCard( - onTap: onTap == null - ? null - : () { - HapticFeedback.selectionClick(); - onTap!(); - }, - pressScale: onTap != null, - padding: const EdgeInsets.all(Sp.x3), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - // Tonal fill so the full-saturation icon on top - // doesn't sit on an equally-bright wash of itself. - color: AppColors.tonalFill(accent), - borderRadius: BorderRadius.circular(R.chip), - ), - child: AppIcon(icon, size: 16, color: accent), - ), - const SizedBox(width: Sp.x2), - Expanded( - child: Text( - label, - style: AppText.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (confidence != null) ConfDot(confidence!), - ], - ), - const SizedBox(height: Sp.x3), - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - if (value == null) - metricDash(24) - else - Flexible( - child: Text( - value!, - style: AppText.metric.copyWith(fontSize: 22), - overflow: TextOverflow.ellipsis, - ), - ), - if (unit != null && value != null) ...[ - const SizedBox(width: 4), - Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Text( - unit!, - style: AppText.caption.copyWith( - color: AppColors.inkMuted, - fontSize: 11, - ), - ), - ), - ], - ], - ), - ], - ), - if (deltaPct != null || tag != null || spark != null) ...[ - const SizedBox(height: Sp.x2), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (deltaPct != null) - Flexible( - child: DeltaChip(deltaPct, goodIsUp: deltaGoodIsUp), - ), - if (tag != null) ...[ - if (deltaPct != null) const SizedBox(width: Sp.x2), - Flexible(child: tag!), - ], - ], - ), - ), - if (spark != null && spark!.isNotEmpty) - Padding( - padding: const EdgeInsets.only(left: Sp.x2), - child: SizedBox( - width: 48, - child: MiniBars(spark!, color: accent, height: 22), - ), - ), - ], - ), - ], - ], - ), - ), - ); - } -} - /// FormChart — Banister Fitness vs Fatigue dual line (with a soft band between), /// for the Body tab. Pass aligned series (oldest→newest); nulls are skipped. class FormChart extends StatelessWidget { @@ -1471,70 +1126,3 @@ class FormChart extends StatelessWidget { ); } } - -/// CalendarHeatmap — a month grid (weeks × 7) of cells colored by a metric. Pass -/// day entries with a 0..1 intensity `t` and a base color; null `t` = no data. -class CalendarHeatmap extends StatelessWidget { - final List<({DateTime date, double? t})> days; - final Color? color; - final double cell; - const CalendarHeatmap({ - super.key, - required this.days, - this.color, - this.cell = 16, - }); - @override - Widget build(BuildContext context) { - final color = this.color ?? AppColors.good; - if (days.isEmpty) return const SizedBox.shrink(); - const wd = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; - // Pad the front so the first day lands on its weekday column (Mon=0). - final first = days.first.date; - final lead = (first.weekday + 6) % 7; // Mon=0 - final cells = <({DateTime? date, double? t})>[ - for (int i = 0; i < lead; i++) (date: null, t: null), - for (final d in days) (date: d.date, t: d.t), - ]; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - for (final l in wd) - SizedBox( - width: cell + 4, - child: Text( - l, - textAlign: TextAlign.center, - style: AppText.captionMuted, - ), - ), - ], - ), - const SizedBox(height: 4), - Wrap( - spacing: 4, - runSpacing: 4, - children: [ - for (final c in cells) - Container( - width: cell, - height: cell, - decoration: BoxDecoration( - color: c.date == null - ? Colors.transparent - : (c.t == null - ? AppColors.surfaceSunk - : color.withValues( - alpha: (0.18 + 0.82 * c.t!.clamp(0, 1)), - )), - borderRadius: BorderRadius.circular(4), - ), - ), - ], - ), - ], - ); - } -} diff --git a/lib/ui/kit/kit.dart b/lib/ui/kit/kit.dart index 4b832eb8..aac83165 100644 --- a/lib/ui/kit/kit.dart +++ b/lib/ui/kit/kit.dart @@ -223,27 +223,6 @@ class GlowCard extends StatelessWidget { } } -/// Dark hero card (device, splash overlays). -class NightCard extends StatelessWidget { - final Widget child; - final EdgeInsetsGeometry padding; - final VoidCallback? onTap; - const NightCard({ - super.key, - required this.child, - this.padding = const EdgeInsets.all(Sp.x6), - this.onTap, - }); - @override - Widget build(BuildContext context) => ProCard( - padding: padding, - onTap: onTap, - color: AppColors.night, - shadow: Shadows.lift, - child: child, - ); -} - /// Wrap each non-spacer widget in a hand-built list with a staggered [Entrance] /// (delay by list position), for a one-time fade-up reveal of a ListView's /// children. Bare [SizedBox] spacers pass through untouched so gaps don't move. diff --git a/lib/ui/kit/os_icons.dart b/lib/ui/kit/os_icons.dart index 13e3b716..5ec13dfb 100644 --- a/lib/ui/kit/os_icons.dart +++ b/lib/ui/kit/os_icons.dart @@ -115,6 +115,7 @@ enum OsIcon { battery, bluetooth, settings, + share, privacy, sync, info, @@ -229,6 +230,8 @@ const Map _glyphs = { OsIcon.battery: Iconsax.battery_full, OsIcon.bluetooth: Iconsax.bluetooth, OsIcon.settings: FluentIcons.settings_24_regular, + // Utility chrome → Fluent, per the pack policy in this file's header. + OsIcon.share: FluentIcons.share_24_regular, OsIcon.privacy: PhosphorIconsDuotone.shield, OsIcon.sync: PhosphorIconsDuotone.arrowsClockwise, OsIcon.info: FluentIcons.info_24_regular, diff --git a/lib/ui/kit/route_map.dart b/lib/ui/kit/route_map.dart index e03dafee..ce8d0831 100644 --- a/lib/ui/kit/route_map.dart +++ b/lib/ui/kit/route_map.dart @@ -68,6 +68,35 @@ const String _kUserAgent = 'wtf.openstrap.edge'; /// in" even though it's technically "fitting" correctly. const double kRouteMapMaxAutoZoom = 17.0; +/// Deepest zoom the tile SOURCE serves, and the camera's hard ceiling. +/// +/// The grey-map bug lived here, and the first fix treated the symptom. The +/// distinction that matters is flutter_map's own: +/// +/// • `TileLayer.maxZoom` — above this the layer is NOT DRAWN AT ALL. +/// Its docs say to leave it infinite "so that there are tiles always +/// displayed"; it exists for swapping in a different layer when zoomed in. +/// • `TileLayer.maxNativeZoom` — above this, tiles at THIS level are +/// displayed and SCALED. This is the one that describes a tile source. +/// +/// The layer was setting `maxZoom: 19`, so past z19 it drew nothing and our +/// luminance-inverting ColorFilter rendered that void as a flat grey slab +/// rather than something obviously blank. Capping the camera hid it at the +/// extreme but left the same cliff in place. Now `maxZoom` is left at its +/// default (infinite) and `maxNativeZoom` describes the source, so every +/// reachable zoom has pixels — slightly soft past the native level, never +/// blank. +/// +/// Note flutter_map subtracts 1 from `maxNativeZoom` when it SIMULATES retina +/// (tile_layer.dart:312). We request native retina tiles instead (see +/// `retinaMode` below), so that subtraction does not apply here. +const double kRouteMapMaxTileZoom = 19.0; + +/// Floor for the camera. Below roughly this the basemap is continents and the +/// route is a dot — zooming further out is never useful here and only risks +/// the same empty-tile washout at the other end. +const double kRouteMapMinZoom = 3.0; + /// Desaturate + invert-luminance + warm-tint every tile pixel in one pass: /// each output channel is `-(0.2126R + 0.7152G + 0.0722B) + offset`, with the /// offset solved so a typical OSM land/background luminance (~240) lands on @@ -82,7 +111,7 @@ const List _kMapTileMatrix = [ 0, 0, 0, 1, 0, ]; -class RouteMapView extends StatelessWidget { +class RouteMapView extends StatefulWidget { /// Route vertices in order, each already tagged with its HR zone (0..5) or /// null (drawn neutral). final List vertices; @@ -116,7 +145,68 @@ class RouteMapView extends StatelessWidget { this.borderRadius = const BorderRadius.all(Radius.circular(R.cardSm)), }); - List get _points => [for (final v in vertices) v.pos]; + @override + State createState() => _RouteMapViewState(); +} + +/// PERFORMANCE — why this is stateful and memoized. +/// +/// Polyline building is O(N) over the whole route AND allocates fresh +/// `Polyline` + `List` objects. flutter_map keys its projection and +/// simplification caches on element identity, so handing it new instances +/// forces a full LatLng→Mercator projection plus a Douglas-Peucker pass over +/// every point, for BOTH the glow and the crisp layer, on the UI thread. +/// +/// As a StatelessWidget this ran on every single build. On the live map that +/// meant once per 1 Hz session tick (~3,600 vertices an hour ⇒ ~10k point +/// projections/second by minute 90), and on the finish screen it ran once per +/// FRAME of the reveal animation. That is the jank, the heat, and a large part +/// of the "app closed mid-ride" ANR. +/// +/// Now the work is redone only when the path actually changes. +class _RouteMapViewState extends State { + List? _builtFrom; + Object? _builtPalette; + bool? _builtInteractive; + List _points = const []; + List _glow = const []; + List _crisp = const []; + + @override + void initState() { + super.initState(); + _rebuildIfNeeded(); + } + + @override + void didUpdateWidget(RouteMapView old) { + super.didUpdateWidget(old); + _rebuildIfNeeded(); + } + + /// Identity check, not a deep compare: RouteTracker publishes a NEW + /// unmodifiable list per accepted fix, so identity changes exactly when the + /// path really grew. A deep compare would cost as much as the rebuild. + /// + /// The key includes the palette and `interactive` because the cached + /// polylines bake BOTH in: `_colorFor` resolves AppColors.zone() from the + /// active palette, and the stroke width comes from `interactive`. Keying on + /// the vertex list alone meant a theme switch left a finished route drawn in + /// the old palette's colours until the route object itself changed — and the + /// design gallery toggles the theme with exactly this widget on screen. + void _rebuildIfNeeded() { + if (identical(_builtFrom, widget.vertices) && + identical(_builtPalette, AppColors.active) && + _builtInteractive == widget.interactive) { + return; + } + _builtFrom = widget.vertices; + _builtPalette = AppColors.active; + _builtInteractive = widget.interactive; + _points = [for (final v in widget.vertices) v.pos]; + _glow = _polylines(glow: true); + _crisp = _polylines(); + } Color _colorFor(int? zone) => zone == null ? AppColors.inkMuted : AppColors.zone(zone); @@ -140,11 +230,11 @@ class RouteMapView extends StatelessWidget { } List _polylines({bool glow = false}) { - final v = vertices; + final v = widget.vertices; if (v.length < 2) return const []; final out = []; Color edgeColor(int i) => _colorFor(v[i + 1].zone); - final width = interactive ? 5.0 : 4.0; + final width = widget.interactive ? 5.0 : 4.0; var i = 0; while (i < v.length - 1) { if (v[i + 1].gapBefore || !_validPos(v, i) || !_validPos(v, i + 1)) { @@ -177,6 +267,11 @@ class RouteMapView extends StatelessWidget { @override Widget build(BuildContext context) { + // Also checked here, not only in didUpdateWidget: the palette is a global + // static, so a theme switch that leaves this widget instance untouched + // would otherwise never invalidate the cache. Three identity comparisons + // when nothing changed. + _rebuildIfNeeded(); // Drop any non-finite GPS coordinate (a bad fix can carry NaN/Inf lat/lng). // Left in, it makes LatLngBounds + camera-fit NaN and crashes the tile layer // at build (NaN.toInt in flutter_map's _clampToNativeZoom) — a real FATAL. @@ -188,7 +283,7 @@ class RouteMapView extends StatelessWidget { // Detect gesture-driven camera moves so a live map can stop auto-following. void onPositionChanged(MapCamera camera, bool hasGesture) { - if (hasGesture) onUserPan?.call(); + if (hasGesture) widget.onUserPan?.call(); } // Only bounds-fit when the box has real area. A zero-area box (every point @@ -216,21 +311,27 @@ class RouteMapView extends StatelessWidget { maxZoom: kRouteMapMaxAutoZoom, ), onPositionChanged: onPositionChanged, + minZoom: kRouteMapMinZoom, + maxZoom: kRouteMapMaxTileZoom, interactionOptions: InteractionOptions( - flags: interactive ? InteractiveFlag.all : InteractiveFlag.none, + flags: + widget.interactive ? InteractiveFlag.all : InteractiveFlag.none, ), ) : MapOptions( initialCenter: pts.first, initialZoom: 15, onPositionChanged: onPositionChanged, + minZoom: kRouteMapMinZoom, + maxZoom: kRouteMapMaxTileZoom, interactionOptions: InteractionOptions( - flags: interactive ? InteractiveFlag.all : InteractiveFlag.none, + flags: + widget.interactive ? InteractiveFlag.all : InteractiveFlag.none, ), ); final map = FlutterMap( - mapController: controller, + mapController: widget.controller, options: options, children: [ // Our own look, not stock OSM — see the file header + _kMapTileMatrix. @@ -240,19 +341,30 @@ class RouteMapView extends StatelessWidget { urlTemplate: _kOsmTileUrl, subdomains: _kTileSubdomains, userAgentPackageName: _kUserAgent, - maxZoom: 19, + // NOT `maxZoom` — see kRouteMapMaxTileZoom. Leaving the display + // ceiling at its default keeps tiles on screen at every zoom. + maxNativeZoom: kRouteMapMaxTileZoom.round(), + // The URL carries the `{r}` retina placeholder, but flutter_map + // only substitutes it when retinaMode is ON — left unset (the + // default is false) it silently resolved to "" and we fetched + // standard-resolution tiles on every device, then scaled them up + // on a 3× display. That is a soft, slightly muddy basemap + // everywhere, and it shows most in the shared image where the map + // is the whole point. flutter_map logs a warning about exactly + // this; it was being missed in the test noise. + retinaMode: RetinaMode.isHighDensity(context), ), ), // Glow pass BEHIND the crisp line — the route is the only colour // against the monochrome basemap; it needs to read unmistakably as // "the route" at a glance, not a thin GPS-app line. - PolylineLayer(polylines: _polylines(glow: true)), - PolylineLayer(polylines: _polylines()), - if (current != null) + PolylineLayer(polylines: _glow), + PolylineLayer(polylines: _crisp), + if (widget.current != null) MarkerLayer( markers: [ Marker( - point: current!, + point: widget.current!, width: 34, height: 34, child: const _PulseDot(), @@ -264,10 +376,15 @@ class RouteMapView extends StatelessWidget { ); final clipped = ClipRRect( - borderRadius: borderRadius, - child: map, + borderRadius: widget.borderRadius, + // The map paints continuously (tile fades, the live pulse dot) and sits + // inside screens that rebuild at 1 Hz — isolate it so those repaints + // never dirty the surrounding stats/cards. + child: RepaintBoundary(child: map), ); - return height == null ? clipped : SizedBox(height: height, child: clipped); + return widget.height == null + ? clipped + : SizedBox(height: widget.height, child: clipped); } /// A minimal, tucked-away credit — required by both the OSM data licence @@ -369,15 +486,70 @@ class RouteZoneLegend extends StatelessWidget { /// A ProCard with a static route thumbnail + distance / pace summary; tapping /// opens the full interactive map. Render only when `route.hasPath`. -class RouteCard extends StatelessWidget { +class RouteCard extends StatefulWidget { final WorkoutRoute route; final int maxHr; const RouteCard({super.key, required this.route, required this.maxHr}); + @override + State createState() => _RouteCardState(); +} + +/// PERFORMANCE — see the note on [_RouteMapViewState] too. +/// +/// `buildVertices` is O(N) over the route with a binary search into the HR +/// series and a haversine per point, and it allocates a `RouteVertex` for each +/// one. The per-point speed scan below is another full pass. +/// +/// This used to sit directly in a StatelessWidget's build. The workout finish +/// screen wraps its whole list in an `AnimatedBuilder`, so for a 60-minute ride +/// (~3,600 points) that was roughly 200k trig ops and allocations PER SECOND +/// for the duration of the reveal animation — which is exactly why the finish +/// screen felt broken. Hoisted into state and recomputed only when the route or +/// max-HR actually changes. +class _RouteCardState extends State { + WorkoutRoute? _builtFrom; + int? _builtMaxHr; + List _vertices = const []; + double? _avgSpeedMps; + double? _maxSpeedMps; + + @override + void initState() { + super.initState(); + _rebuildIfNeeded(); + } + + @override + void didUpdateWidget(RouteCard old) { + super.didUpdateWidget(old); + _rebuildIfNeeded(); + } + + void _rebuildIfNeeded() { + final route = widget.route; + if (identical(_builtFrom, route) && _builtMaxHr == widget.maxHr) return; + _builtFrom = route; + _builtMaxHr = widget.maxHr; + _vertices = rmath.buildVertices(route.points, route.hr, widget.maxHr); + // Avg/max speed from the per-point recorded speeds — a Strava-style stat + // distinct from pace (more natural for cycling, and "max speed" a + // descent/sprint peak that avg-pace/best-split don't surface at all). + final speeds = [ + for (final p in route.points) + if (p.speed != null && p.speed! >= 0) p.speed!, + ]; + _avgSpeedMps = speeds.isEmpty + ? null + : speeds.reduce((a, b) => a + b) / speeds.length; + _maxSpeedMps = speeds.isEmpty ? null : speeds.reduce((a, b) => a > b ? a : b); + } + @override Widget build(BuildContext context) { final units = context.watch(); - final vertices = rmath.buildVertices(route.points, route.hr, maxHr); + final route = widget.route; + final vertices = _vertices; final avgPace = units.pace(route.distanceMeters, route.movingSec); // Best pace = the fastest FULL split (partial trailing split excluded). final unitMeters = units.distanceUnitMeters; @@ -390,18 +562,8 @@ class RouteCard extends StatelessWidget { } final bestPaceText = bestPace == null ? '—' : '${units.formatPace(bestPace)} ${units.paceUnit}'; - // Avg/max speed from the per-point recorded speeds — a Strava-style stat - // distinct from pace (more natural for cycling, and "max speed" a - // descent/sprint peak that avg-pace/best-split don't surface at all). - final speeds = [ - for (final p in route.points) - if (p.speed != null && p.speed! >= 0) p.speed!, - ]; - final avgSpeedMps = speeds.isEmpty - ? null - : speeds.reduce((a, b) => a + b) / speeds.length; - final maxSpeedMps = - speeds.isEmpty ? null : speeds.reduce((a, b) => a > b ? a : b); + final avgSpeedMps = _avgSpeedMps; + final maxSpeedMps = _maxSpeedMps; return ProCard( padding: const EdgeInsets.all(Sp.x4), child: Column( @@ -440,7 +602,7 @@ class RouteCard extends StatelessWidget { // Speed is only meaningful when the platform actually reported it // for this route (older recordings / some Android devices may // have none) — omit the row entirely rather than show a row of "—". - if (speeds.isNotEmpty) ...[ + if (avgSpeedMps != null) ...[ const SizedBox(height: Sp.x4), Row( children: [ diff --git a/lib/ui/timeline/timeline_screen.dart b/lib/ui/timeline/timeline_screen.dart index 999767a8..ce0503be 100644 --- a/lib/ui/timeline/timeline_screen.dart +++ b/lib/ui/timeline/timeline_screen.dart @@ -17,12 +17,14 @@ // HONESTY: only continuously-recorded vitals are drawn — HR, HRV, resp (rolling // RSA) and a RELATIVE skin-temp trend (no absolute °C). HRV/resp are movement- // confounded by day (explained behind the (i)). +// +// This file is presentation only: [TimelineContent] takes an already-loaded day +// bundle. It used to also carry a standalone `TimelineScreen` that fetched the +// bundle itself, but the timeline is only ever reached embedded in the Journey +// screen (which does its own loading), so that wrapper was removed. import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../../data/local_repository.dart'; -import '../../state/app_state.dart'; import '../design/design.dart'; // Strong, opposite, nature-matched vital colours (deliberately NOT the light @@ -111,77 +113,6 @@ class _Band { const _Band(this.label, this.color, this.start, this.end, this.icon); } -class TimelineScreen extends StatefulWidget { - final String date; - const TimelineScreen({super.key, required this.date}); - @override - State createState() => _TimelineScreenState(); -} - -enum _Phase { loading, ready, empty, error } - -class _TimelineScreenState extends State { - _Phase _phase = _Phase.loading; - Map _data = const {}; - - @override - void initState() { - super.initState(); - _load(); - } - - Future _load() async { - setState(() => _phase = _Phase.loading); - try { - final LocalRepository? repo = context.read().repo; - final d = await repo?.getDayTimeline(widget.date); - if (!mounted) return; - setState(() { - _data = d ?? const {}; - _phase = TimelineContent.hasVitals(_data) - ? _Phase.ready - : _Phase.empty; - }); - } catch (_) { - if (mounted) setState(() => _phase = _Phase.error); - } - } - - @override - Widget build(BuildContext context) { - return AppScaffold( - title: 'Your timeline', - subtitle: 'Every vital, one day', - children: [ - if (_phase == _Phase.loading) ...[ - Skeleton.tileRow(rows: 1), - const SizedBox(height: Sp.x4), - Skeleton.chart(height: 280), - ] else if (_phase == _Phase.empty) - StateCard( - icon: OsIcon.heartRate, - title: 'No timeline yet', - message: - 'Wear the strap through the day and your merged vitals ' - 'timeline will appear here.', - actionLabel: 'Try again', - onAction: _load, - ) - else if (_phase == _Phase.error) - StateCard( - icon: OsIcon.sync, - title: "Couldn't load your timeline", - message: 'Please try again.', - actionLabel: 'Try again', - onAction: _load, - ) - else - TimelineContent(data: _data), - ], - ); - } -} - /// Pure presentation for the merged-vitals board (render-testable without a /// repo): selector chips, the merged normalized chart, peak/low BigStats for /// the active vital, and the day's event list. diff --git a/lib/ui/today/today_screen.dart b/lib/ui/today/today_screen.dart index a6d22cbc..7d450e0a 100644 --- a/lib/ui/today/today_screen.dart +++ b/lib/ui/today/today_screen.dart @@ -960,12 +960,15 @@ class TodayVitals extends StatelessWidget { // uses (40/66) — the ring's word and the AI briefing's band must always // agree, or the app can tell the user two different things about the // same score again (exactly the bug this shared source of truth fixes). - final word = score == null - ? null + // + // The word is a state you're in, phrased as what today's training should + // be, and renders as a state chip inside the ring (see OrbitScore.word). + final (word, wordIcon) = score == null + ? (null, null) : switch (readinessBand(score)) { - 'good' => 'Primed', - 'moderate' => 'Steady', - _ => 'Run easy', + 'good' => ('Push', OsIcon.intensity), + 'moderate' => ('Focus', OsIcon.activity), + _ => ('Recover', OsIcon.calm), }; // Honest "still learning you" center: nights-to-go over a dashed @@ -997,6 +1000,7 @@ class TodayVitals extends StatelessWidget { score: score, label: 'Readiness', word: word, + wordIcon: wordIcon, color: accent, confidence: score == null ? 0.3 : r.confidence, ringFill: (score == null && fill != null) ? fill.$1 / fill.$2 : null, diff --git a/lib/ui/workouts/workouts_screen.dart b/lib/ui/workouts/workouts_screen.dart index f3d264f7..bc40ee98 100644 --- a/lib/ui/workouts/workouts_screen.dart +++ b/lib/ui/workouts/workouts_screen.dart @@ -17,6 +17,7 @@ import '../../models/payloads.dart'; import '../../data/day_label.dart'; import '../../data/db.dart'; import '../activity/live_session_screen.dart'; +import '../activity/workout_share_card.dart'; import '../../theme/theme_switcher.dart'; import '../design/design.dart'; import '../kit/route_map.dart'; @@ -904,10 +905,40 @@ class WorkoutFeedCard extends StatelessWidget { } /// Post-workout breakdown (also the tap target from the list). -class WorkoutDetailScreen extends StatelessWidget { +class WorkoutDetailScreen extends StatefulWidget { final String id; const WorkoutDetailScreen({super.key, required this.id}); + @override + State createState() => _WorkoutDetailScreenState(); +} + +class _WorkoutDetailScreenState extends State { + /// Published by the body once the workout (and its route) have loaded. + /// + /// The share action lives in the scaffold's action row but the data it needs + /// is loaded by the body below it, so the body hands it up here. Held as a + /// notifier rather than lifted into setState so a load doesn't rebuild the + /// whole screen just to enable one button. + final ValueNotifier _shareData = ValueNotifier(null); + + String get id => widget.id; + + @override + void dispose() { + _shareData.dispose(); + super.dispose(); + } + + Future _share() async { + final data = _shareData.value; + if (data == null) return; + await Navigator.of(context).push( + themedRoute((_) => WorkoutSharePreviewScreen(data: data), + name: 'WorkoutSharePreviewScreen'), + ); + } + Future _delete(BuildContext context) async { final ok = await showDialog( context: context, @@ -954,16 +985,32 @@ class WorkoutDetailScreen extends StatelessWidget { title: 'Workout', largeTitle: false, actions: [ + // Sharing a past workout is the same action as sharing one you just + // finished, and produces the same card — it was only reachable from + // the finish screen, so the moment you left that screen the workout + // became unshareable. Appears once there is something to share. + ValueListenableBuilder( + valueListenable: _shareData, + builder: (context, data, _) => data == null + ? const SizedBox.shrink() + : Padding( + padding: const EdgeInsets.only(right: Sp.x2), + child: RoundIconButton(OsIcon.share, onTap: _share), + ), + ), RoundIconButton(OsIcon.trash, onTap: () => _delete(context)), ], - body: _WorkoutDetailBody(id: id), + body: _WorkoutDetailBody(id: id, shareData: _shareData), ); } } class _WorkoutDetailBody extends StatefulWidget { final String id; - const _WorkoutDetailBody({required this.id}); + + /// Filled in once loaded, so the scaffold above can offer a share action. + final ValueNotifier shareData; + const _WorkoutDetailBody({required this.id, required this.shareData}); @override State<_WorkoutDetailBody> createState() => _WorkoutDetailBodyState(); } @@ -994,6 +1041,7 @@ class _WorkoutDetailBodyState extends State<_WorkoutDetailBody> { _route = route; _loading = false; }); + _publishShareData(); } } catch (_) { if (mounted) { @@ -1002,6 +1050,43 @@ class _WorkoutDetailBodyState extends State<_WorkoutDetailBody> { } } + /// Hand the scaffold a ready-to-share composition. + /// + /// Built through [buildWorkoutShareData] — the same function the finish + /// screen uses — so sharing a run from here and from the finish screen + /// produce identical cards rather than two subtly different ones. + void _publishShareData() { + final d = _d; + if (d == null || d.isEmpty) { + widget.shareData.value = null; + return; + } + // A still-running workout has no final numbers yet; sharing one would post + // a half-finished card. + if (d['status'] == 'live') { + widget.shareData.value = null; + return; + } + final startTs = d['start_ts'] as int?; + final endTs = d['end_ts'] as int?; + final durationSec = (startTs != null && endTs != null && endTs > startTs) + ? endTs - startTs + : ((d['duration_min'] as num?)?.toDouble() ?? 0) * 60; + widget.shareData.value = buildWorkoutShareData( + units: context.read(), + type: (d['type'] as String?) ?? '', + duration: Duration(seconds: durationSec.round()), + when: startTs != null && startTs > 0 + ? DateTime.fromMillisecondsSinceEpoch(startTs * 1000).toLocal() + : DateTime.now(), + maxHr: context.read().maxHr, + strain: (d['strain'] as num?)?.toDouble() ?? 0, + calories: (d['calories'] as num?)?.toInt() ?? 0, + route: _route, + avgHr: (d['avg_hr'] as num?)?.toInt(), + ); + } + Future _correctType() async { final d = _d; if (d == null) return; diff --git a/test/absent_not_zero_test.dart b/test/absent_not_zero_test.dart index 79487172..3e4d80d6 100644 --- a/test/absent_not_zero_test.dart +++ b/test/absent_not_zero_test.dart @@ -11,7 +11,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/theme/theme.dart'; import 'package:openstrap_edge/theme/tokens.dart'; -import 'package:openstrap_edge/ui/design/recap_card.dart' show RecapCard; import 'package:openstrap_edge/ui/kit/charts.dart' show HrReplayOverlay, LabeledBars, MiniBars, TimeSeriesPoint; import 'package:openstrap_edge/ui/kit/kit.dart' show OsIcon; @@ -435,16 +434,16 @@ void main() { }); }); - // ── recap strip gaps ────────────────────────────────────────────────────── - group('RecapCard week strip', () { + // ── week-strip gaps ─────────────────────────────────────────────────────── + // This used to go through RecapCard (now deleted — it had no call site + // outside the gallery). The invariant it guarded is MiniBars' own: a null + // day holds its slot instead of sliding the rest of the week left. + group('MiniBars week strip', () { testWidgets('keeps a missing day in place instead of shifting the week ' 'left', (t) async { _phone(t); - await t.pumpWidget(_host(const RecapCard( - title: 'Weekly recap', - value: '7h 12m', - caption: 'daily average', - bars: [420.0, 430.0, null, 445.0, 455.0, 460.0, 470.0], + await t.pumpWidget(_host(const MiniBars( + [420.0, 430.0, null, 445.0, 455.0, 460.0, 470.0], ))); await t.pump(const Duration(milliseconds: 700)); final bars = t.widget(find.byType(MiniBars)); diff --git a/test/ai_briefing_test.dart b/test/ai_briefing_test.dart index 9d536768..2eee28dd 100644 --- a/test/ai_briefing_test.dart +++ b/test/ai_briefing_test.dart @@ -148,13 +148,13 @@ void main() { test( 'readinessBand cuts at 40/66 — MUST match the Today ring\'s own ' - 'word-thresholds (score>=66 Primed, >=40 Steady, else Run easy) or ' + 'word-thresholds (score>=66 Push, >=40 Focus, else Recover) or ' 'the briefing and the ring can disagree again', () { // Just below/at each ring boundary. - expect(readinessBand(39), 'low'); // ring: "Run easy" - expect(readinessBand(40), 'moderate'); // ring: "Steady" - expect(readinessBand(65), 'moderate'); // ring: "Steady" - expect(readinessBand(66), 'good'); // ring: "Primed" + expect(readinessBand(39), 'low'); // ring: "Recover" + expect(readinessBand(40), 'moderate'); // ring: "Focus" + expect(readinessBand(65), 'moderate'); // ring: "Focus" + expect(readinessBand(66), 'good'); // ring: "Push" expect(readinessBand(100), 'good'); expect(readinessBand(0), 'low'); }); diff --git a/test/core_screens_test.dart b/test/core_screens_test.dart index 157a5dc5..f487d064 100644 --- a/test/core_screens_test.dart +++ b/test/core_screens_test.dart @@ -112,7 +112,7 @@ void main() { ); await t.pump(const Duration(milliseconds: 1200)); expect(find.text('READINESS'), findsOneWidget); - expect(find.text('Primed'), findsOneWidget); // 82 → primed + expect(find.text('Push'), findsOneWidget); // 82 → good band → 'Push' chip expect(find.text('48'), findsWidgets); // HRV value expect(find.text('52'), findsWidgets); // RHR value expect(find.text('12.4'), findsOneWidget); // strain — quick-stats row diff --git a/test/design_redesign_test.dart b/test/design_redesign_test.dart index 72897f7b..f1121495 100644 --- a/test/design_redesign_test.dart +++ b/test/design_redesign_test.dart @@ -14,10 +14,8 @@ import 'package:openstrap_edge/theme/tokens.dart'; import 'package:openstrap_edge/ui/design/ai_hero.dart'; import 'package:openstrap_edge/ui/design/bento.dart'; import 'package:openstrap_edge/ui/design/big_stat.dart'; -import 'package:openstrap_edge/ui/design/domains.dart'; import 'package:openstrap_edge/ui/design/hypnogram.dart'; import 'package:openstrap_edge/ui/design/orbit_score.dart'; -import 'package:openstrap_edge/ui/design/radial_heatmap.dart'; import 'package:openstrap_edge/ui/design/recap_card.dart'; import 'package:openstrap_edge/ui/design/ring_week.dart'; import 'package:openstrap_edge/ui/design/state_chips.dart'; @@ -62,47 +60,33 @@ void main() { tearDown(() => AppColors.active = kLightPalette); group('OrbitScore', () { - testWidgets('score, word, label render; core + satellite taps fire', ( + testWidgets('score, label + word chip render; core tap fires', ( t, ) async { _phone(t); var core = 0; - final opened = []; await t.pumpWidget( _host( OrbitScore( score: 82, label: 'Readiness', - word: 'Primed', + word: 'Push', + wordIcon: OsIcon.intensity, onTap: () => core++, - satellites: [ - OrbitSatellite( - icon: OsIcon.sleep, - label: 'Sleep', - onTap: () => opened.add('sleep'), - ), - OrbitSatellite( - icon: OsIcon.heart, - label: 'Heart', - onTap: () => opened.add('heart'), - ), - ], ), ), ); await t.pump(const Duration(milliseconds: 1200)); expect(find.text('READINESS'), findsOneWidget); expect(find.text('82'), findsOneWidget); - expect(find.text('Primed'), findsOneWidget); - expect(find.text('Sleep'), findsOneWidget); + // The status word renders as a StateChipView pill, not bare text. + expect(find.text('Push'), findsOneWidget); + expect(find.byType(StateChipView), findsOneWidget); expect(t.takeException(), isNull); await t.tap(find.text('82')); await t.pump(const Duration(milliseconds: 250)); expect(core, 1); - await t.tap(find.text('Sleep')); - await t.pump(const Duration(milliseconds: 250)); - expect(opened, ['sleep']); }); testWidgets('null score with ringFill + custom center stays honest', ( @@ -282,24 +266,7 @@ void main() { }); }); - group('RadialHeatmap + RingWeek', () { - testWidgets('RadialHeatmap handles nulls + labels without throwing', ( - t, - ) async { - _phone(t); - await t.pumpWidget( - _host( - RadialHeatmap( - values: const [0.1, null, 0.8, 1.0, 0.4, 0.0, null, 0.6], - color: DomainAccent.strain, - labels: const ['12a', '6a', '12p', '6p'], - ), - ), - ); - await t.pump(const Duration(milliseconds: 1100)); - expect(t.takeException(), isNull); - }); - + group('RingWeek', () { testWidgets('RingWeek renders custom labels + null days', (t) async { _phone(t); await t.pumpWidget( @@ -318,65 +285,60 @@ void main() { }); }); - group('StateChips + RecapCard + MedalCard + AiHero', () { - testWidgets('StateChips selects on tap', (t) async { + group('StateChipView + MedalCard + AiHero', () { + testWidgets('StateChipView fires onTap; display-only chip does not', ( + t, + ) async { _phone(t); - var sel = 0; + var taps = 0; await t.pumpWidget( _host( - StatefulBuilder( - builder: (context, setState) => StateChips( - chips: const [ - StateChip('Energize', emoji: '⚡'), - StateChip('Recover', emoji: '🛌'), - ], - selected: sel, - onSelect: (i) => setState(() => sel = i), - ), + Column( + children: [ + StateChipView( + const StateChip('Recover', icon: OsIcon.calm), + selected: true, + onTap: () => taps++, + ), + // No onTap → a display badge. Tapping it must stay inert. + const StateChipView( + StateChip('Push', icon: OsIcon.intensity), + selected: true, + ), + ], ), ), ); await t.pump(const Duration(milliseconds: 300)); await t.tap(find.text('Recover')); await t.pump(const Duration(milliseconds: 300)); - expect(sel, 1); + expect(taps, 1); + await t.tap(find.text('Push')); + await t.pump(const Duration(milliseconds: 300)); + expect(taps, 1); + expect(t.takeException(), isNull); }); - testWidgets('RecapCard + MedalCard render and tap through', (t) async { + testWidgets('MedalCard renders and taps through', (t) async { for (final p in [kLightPalette, kDarkPalette]) { _phone(t); var taps = 0; await t.pumpWidget( _host( - Column( - children: [ - RecapCard( - title: 'Weekly recap', - highlight: 'You slept 40 min more than usual.', - value: '7h 12m', - caption: 'daily average', - bars: const [6.2, 7.5, 8.1, 6.9, 7.2, 8.4, 7.1], - onTap: () => taps++, - ), - const SizedBox(height: Sp.x3), - MedalCard( - medal: '5K', - overline: 'Personal record', - title: 'Fastest 5k — 24:31', - subtitle: 'Tuesday morning run', - onTap: () {}, - ), - ], + MedalCard( + medal: '5K', + overline: 'Personal record', + title: 'Fastest 5k — 24:31', + subtitle: 'Tuesday morning run', + onTap: () => taps++, ), palette: p, ), ); await t.pump(const Duration(milliseconds: 500)); - expect(find.text('WEEKLY RECAP'), findsOneWidget); - expect(find.text('7h 12m'), findsOneWidget); expect(find.text('Fastest 5k — 24:31'), findsOneWidget); expect(t.takeException(), isNull); - await t.tap(find.text('7h 12m')); + await t.tap(find.text('Fastest 5k — 24:31')); await t.pump(const Duration(milliseconds: 250)); expect(taps, 1); } @@ -451,7 +413,7 @@ void main() { // Hero (no floating satellites anymore) + the demoted quick-stats // row underneath it. expect(find.text('READINESS'), findsOneWidget); - expect(find.text('Primed'), findsOneWidget); + expect(find.text('Push'), findsOneWidget); expect(find.text('Sleep'), findsOneWidget); // quick-stats row route // Bento numbers. RHR also appears in the quick-stats row (Heart), // so it matches >1; HRV is bento-only (shown as an AI-briefing diff --git a/test/design_system_test.dart b/test/design_system_test.dart index 25d309df..954431c9 100644 --- a/test/design_system_test.dart +++ b/test/design_system_test.dart @@ -8,6 +8,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; +import 'package:openstrap_edge/state/units_controller.dart'; import 'package:openstrap_edge/theme/theme.dart'; import 'package:openstrap_edge/theme/theme_controller.dart'; import 'package:openstrap_edge/theme/tokens.dart'; @@ -329,14 +330,43 @@ void main() { t.view.physicalSize = const Size(390, 844); t.view.devicePixelRatio = 1.0; addTearDown(t.view.reset); + + // The gallery now renders the real share card, which means real map + // tiles — and flutter_test's HTTP mock answers 400 for every request, + // so each tile throws through the image-resource service. + // + // Filtered rather than drained: this test exists to catch RenderFlex + // overflow across every section, so swallowing ALL exceptions would + // gut it. Only the image-resource library is dropped; everything else + // still fails the test. (Installed here in the body, not in setUp — + // the test binding replaces FlutterError.onError before the body runs.) + final previousOnError = FlutterError.onError; + FlutterError.onError = (details) { + if (details.library == 'image resource service') return; + if (details.exception.toString().contains('ClientException')) return; + previousOnError?.call(details); + }; + addTearDown(() => FlutterError.onError = previousOnError); + for (final (palette, choice) in [ (kLightPalette, AppThemeChoice.light), (kDarkPalette, AppThemeChoice.dark), ]) { AppColors.active = palette; await t.pumpWidget( - ChangeNotifierProvider.value( - value: ThemeController.seed(choice, Brightness.light), + MultiProvider( + providers: [ + ChangeNotifierProvider.value( + value: ThemeController.seed(choice, Brightness.light), + ), + // The gallery's share-card demo formats distance and pace + // through UnitsController, exactly as the finish screen does. + // In the app this always sits above the gallery; the test host + // has to mirror that. + ChangeNotifierProvider.value( + value: UnitsController.seed(UnitSystem.metric), + ), + ], child: MaterialApp( theme: buildOpenStrapTheme(palette), home: const DesignGalleryScreen(), diff --git a/test/live_session_layout_test.dart b/test/live_session_layout_test.dart new file mode 100644 index 00000000..76697d48 --- /dev/null +++ b/test/live_session_layout_test.dart @@ -0,0 +1,205 @@ +// Layout regressions for the live activity screen (run / ride / walk). +// +// The 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; +// • the ring-mode core was a fixed 270 px in a Center, with the session +// clock absolutely positioned above it and the panel below, so all three +// collided on a shorter phone. +// +// The fix is structural: a bounded hero and a metric sheet as SIBLINGS in a +// Column. These tests assert that property directly at several real device +// sizes, so a future "just Positioned it" change fails loudly. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/state/units_controller.dart'; +import 'package:openstrap_edge/theme/theme.dart'; +import 'package:openstrap_edge/theme/tokens.dart'; +import 'package:openstrap_edge/ui/activity/live_session_screen.dart'; +import 'package:path/path.dart' as p; +import 'package:provider/provider.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// Real handset sizes, smallest first — the small ones are where the old +/// fixed-size layers collided. +const _sizes = { + 'iPhone SE': Size(375, 667), + 'iPhone 15': Size(393, 852), + 'Pixel 7': Size(412, 915), +}; + +Widget _host(Widget child, AppState app) => MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: app), + ChangeNotifierProvider.value( + value: UnitsController.seed(UnitSystem.metric), + ), + ], + child: MaterialApp( + theme: buildOpenStrapTheme(kDarkPalette), + home: child, + ), + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_live_session_layout_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + AppColors.active = kDarkPalette; + }); + + AppState liveApp({required int hr, String type = 'run'}) { + final app = AppState.forTesting(); + app.activeWorkout = LiveWorkoutState( + startTime: DateTime.now().subtract(const Duration(minutes: 24)), + targetKcal: 300, + workoutId: 'live-1', + type: type, + )..currentHr = hr; + return app; + } + + for (final entry in _sizes.entries) { + testWidgets('${entry.key}: hero and metric sheet never overlap', + (t) async { + t.view.physicalSize = entry.value; + t.view.devicePixelRatio = 1.0; + addTearDown(t.view.reset); + + final app = liveApp(hr: 148); + addTearDown(app.dispose); + + await t.pumpWidget(_host(const LiveSessionScreen(), app)); + await t.pump(const Duration(milliseconds: 300)); + + // Assert against the BOTTOM-MOST hero element, not the clock at the + // top — the clock clears the sheet even in the broken layout, so + // asserting on it would pass vacuously (it did; that is why this + // compares the zone label instead). + // + // The zone name is the last thing in the hero column. If the sheet ever + // goes back to floating over the hero, this is what it covers first. + final sheetTop = t.getRect(find.text('HOLD TO FINISH')).top; + final zoneLabel = t.getRect(find.textContaining('·').first); + expect( + zoneLabel.bottom, + lessThanOrEqualTo(sheetTop), + reason: 'hero content must not be painted underneath the sheet', + ); + // And the hero's own hard-anchored overlay (the top rail) must not be + // pushed off-screen by a hero that grew past its bounds. + expect(t.takeException(), isNull, + reason: 'no overflow at ${entry.key} (${entry.value})'); + }); + } + + testWidgets('the top rail keeps its contents inside the viewport', + (t) async { + t.view.physicalSize = _sizes['iPhone SE']!; + t.view.devicePixelRatio = 1.0; + addTearDown(t.view.reset); + + final app = liveApp(hr: 132); + addTearDown(app.dispose); + + await t.pumpWidget(_host(const LiveSessionScreen(), app)); + await t.pump(const Duration(milliseconds: 300)); + + // The view toggle moved OUT of the top rail and into the sheet, so with no + // location issue the rail is empty by design. What must hold is that + // anything it does show stays on screen rather than under the notch. + final chip = find.textContaining('Location off'); + if (chip.evaluate().isNotEmpty) { + final r = t.getRect(chip); + expect(r.top, greaterThanOrEqualTo(0)); + expect(r.right, lessThanOrEqualTo(_sizes['iPhone SE']!.width)); + } + expect(t.takeException(), isNull); + }); + + testWidgets('heart mode shows the clock and BPM ONCE, in the hero only', + (t) async { + t.view.physicalSize = _sizes['iPhone 15']!; + t.view.devicePixelRatio = 1.0; + addTearDown(t.view.reset); + + final app = liveApp(hr: 148); + addTearDown(app.dispose); + + await t.pumpWidget(_host(const LiveSessionScreen(), app)); + await t.pump(const Duration(milliseconds: 300)); + + // The hero already carries the session clock at 40 px and the BPM at ring + // scale with its zone name. The sheet used to repeat both, printing the + // same number twice on one screen. + expect(find.text('DURATION'), findsOneWidget); + expect(find.text('ELAPSED'), findsNothing, + reason: 'the sheet must not repeat the clock in heart mode'); + expect(find.text('BPM'), findsOneWidget); + expect(find.text('148'), findsOneWidget, + reason: 'heart rate belongs to the hero, not also the sheet'); + // The stats that are NOT duplicated still show. + expect(find.text('KCAL'), findsOneWidget); + expect(find.text('STRAIN'), findsOneWidget); + expect(find.text('STEPS'), findsOneWidget); + expect(t.takeException(), isNull); + }); + + testWidgets('the hero clock stays legible at an extreme duration', + (t) async { + t.view.physicalSize = _sizes['iPhone SE']!; + t.view.devicePixelRatio = 1.0; + addTearDown(t.view.reset); + + final app = liveApp(hr: 195); + app.activeWorkout = LiveWorkoutState( + startTime: DateTime.now().subtract(const Duration(hours: 12, minutes: 3)), + targetKcal: 300, + workoutId: 'ultra', + type: 'run', + )..currentHr = 195; + addTearDown(app.dispose); + + await t.pumpWidget(_host(const LiveSessionScreen(), app)); + await t.pump(const Duration(milliseconds: 300)); + + // A 12-hour clock is the widest the primary figure ever gets; it must + // scale down rather than overflow its row. + expect(t.takeException(), isNull); + }); + + testWidgets('the almost-there nudge interpolates its values', (t) async { + t.view.physicalSize = _sizes['iPhone 15']!; + t.view.devicePixelRatio = 1.0; + addTearDown(t.view.reset); + + // The nudge only appears within 5 bpm of the next zone, which is why no + // test ever hit it — and an escaped `\$` in the template shipped, rendering + // the literal text `$gapBpm bpm to ${_zones[zone + 1].label} — push` to the + // athlete. Default maxHr is 190 (age 30), Z4 starts at 0.8 => 152 bpm, so + // 148 sits 4 bpm short of it. + final app = liveApp(hr: 148); + addTearDown(app.dispose); + + await t.pumpWidget(_host(const LiveSessionScreen(), app)); + await t.pump(const Duration(milliseconds: 300)); + + expect(find.textContaining('bpm to'), findsOneWidget); + expect(find.textContaining(r'$gapBpm'), findsNothing, + reason: 'the template must be interpolated, not printed'); + expect(find.textContaining(r'${'), findsNothing, + reason: 'no raw interpolation syntax may reach the screen'); + expect(find.text('4 bpm to Z4 — push'), findsOneWidget); + }); +} diff --git a/test/no_debug_only_apis_test.dart b/test/no_debug_only_apis_test.dart new file mode 100644 index 00000000..676c9e40 --- /dev/null +++ b/test/no_debug_only_apis_test.dart @@ -0,0 +1,111 @@ +// Source hygiene: no assert-stripped Flutter APIs in production code. +// +// THE BUG THIS EXISTS FOR +// +// The workout share flow called `RenderRepaintBoundary.debugNeedsPaint` to +// decide whether to wait a frame before rasterising. That getter is shaped like +// this in the Flutter SDK (rendering/object.dart): +// +// bool get debugNeedsPaint { +// late bool result; +// assert(() { result = _needsPaint; return true; }()); +// return result; +// } +// +// Asserts are stripped in release and profile builds, so `result` is never +// assigned and simply READING the getter throws: +// +// LateInitializationError: Local 'result' has not been initialized. +// +// Sharing therefore worked perfectly in debug and failed on every real build. +// +// Nothing else catches this. `flutter analyze` is happy — it is a legal getter +// call. The entire test suite runs in DEBUG mode, where the assert executes and +// the getter behaves, so no widget or unit test can reproduce it. Only a +// release build on a device does, which is the slowest possible feedback loop. +// +// So this test greps instead. The denylist below was not guessed: it is every +// getter in the Flutter SDK matching the `late result; assert(...)` shape. +// Regenerate it with: +// +// grep -rzoP '\w[\w<>, ?]*\s+get\s+\w+\s*\{\s*late\s+[\w<>, ?]+\s+result;\s*assert\(' \ +// $FLUTTER_ROOT/packages/flutter/lib/src +// +// If a member here is genuinely needed, guard it inside an `assert(() {...})` +// block — never on a code path that runs in release. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// Getters whose value only exists when asserts are enabled. +const _assertStrippedMembers = [ + 'debugNeedsPaint', + 'debugNeedsLayout', + 'debugNeedsCompositedLayerUpdate', +]; + +/// Strip `//` line comments and `/* */` blocks so a member named in an +/// explanatory comment (this file's own history, for one) isn't a false hit. +String _stripComments(String source) { + final noBlocks = source.replaceAll(RegExp(r'/\*.*?\*/', dotAll: true), ''); + return noBlocks + .split('\n') + .map((l) { + final i = l.indexOf('//'); + return i == -1 ? l : l.substring(0, i); + }) + .join('\n'); +} + +void main() { + test('no assert-stripped Flutter APIs are called in lib/', () { + final lib = Directory('lib'); + expect(lib.existsSync(), isTrue, reason: 'run from the package root'); + + final offences = []; + for (final entity in lib.listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) continue; + final code = _stripComments(entity.readAsStringSync()); + final lines = code.split('\n'); + for (var i = 0; i < lines.length; i++) { + for (final member in _assertStrippedMembers) { + // `.member` — a call on an instance. A declaration of the same name + // (we don't have one) wouldn't match the leading dot. + if (lines[i].contains('.$member')) { + offences.add('${entity.path}:${i + 1} → $member'); + } + } + } + } + + expect( + offences, + isEmpty, + reason: 'These read fine in debug and throw LateInitializationError in ' + 'release/profile, because their value is only assigned inside an ' + 'assert. Guard them inside `assert(() { ... }())` or drop them:\n' + '${offences.join('\n')}', + ); + }); + + test('the denylist itself is non-empty and plausible', () { + // A regenerated-but-emptied denylist would make the test above vacuous. + expect(_assertStrippedMembers, isNotEmpty); + expect(_assertStrippedMembers, contains('debugNeedsPaint')); + }); + + test('the comment stripper does not hide a real call', () { + const sample = ''' + // boundary.debugNeedsPaint is mentioned here in prose + final x = boundary.debugNeedsPaint; + '''; + final stripped = _stripComments(sample); + expect(stripped.contains('.debugNeedsPaint'), isTrue, + reason: 'the real call on line 2 must survive stripping'); + expect('\n'.allMatches(stripped).length, greaterThan(1)); + // And a comment-only mention must NOT trip it. + const commentOnly = '// see boundary.debugNeedsPaint for why'; + expect(_stripComments(commentOnly).contains('.debugNeedsPaint'), isFalse); + }); +} diff --git a/test/notification_day_guard_test.dart b/test/notification_day_guard_test.dart index 4babbf43..e607c218 100644 --- a/test/notification_day_guard_test.dart +++ b/test/notification_day_guard_test.dart @@ -63,6 +63,15 @@ Map _quietAllDay() => { 'notif_quiet_end': 1440, }; +/// The mirror of [_quietAllDay], for the cases that expect a present to SUCCEED. +/// +/// These used to pass `{}` and inherit the DEFAULT quiet window of 22:00–07:00. +/// Since nothing here stubs the clock, every "returns true on a real present" +/// assertion failed for nine hours a night on a developer machine and passed on +/// CI purely because CI happened to run at a different hour. Pin the window off +/// so the outcome depends on the code under test, not on what time it is. +Map _quietNever() => {'notif_quiet_enabled': false}; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -84,14 +93,14 @@ void main() { test('returns false when the OS present is refused (permission denied)', () async { - SharedPreferences.setMockInitialValues({}); + SharedPreferences.setMockInitialValues(_quietNever()); final sink = _Sink(grant: false); NotificationCenter.instance.presentSink = sink.call; expect(await NotificationCenter.instance.emit(_recoveryReady()), isFalse); }); test('returns true on a real present', () async { - SharedPreferences.setMockInitialValues({}); + SharedPreferences.setMockInitialValues(_quietNever()); final sink = _Sink(); NotificationCenter.instance.presentSink = sink.call; expect(await NotificationCenter.instance.emit(_recoveryReady()), isTrue); @@ -132,7 +141,7 @@ void main() { test('a permission-denied no-op does NOT burn the day guard either', () async { - SharedPreferences.setMockInitialValues({}); + SharedPreferences.setMockInitialValues(_quietNever()); final sink = _Sink(grant: false); NotificationCenter.instance.presentSink = sink.call; @@ -155,7 +164,7 @@ void main() { test('a real present consumes the guard, and the same day never re-fires', () async { - SharedPreferences.setMockInitialValues({}); + SharedPreferences.setMockInitialValues(_quietNever()); final sink = _Sink(); NotificationCenter.instance.presentSink = sink.call; @@ -173,7 +182,10 @@ void main() { }); test('a NEW day is a fresh guard', () async { - SharedPreferences.setMockInitialValues({kGuardKey: kDay}); + SharedPreferences.setMockInitialValues({ + ..._quietNever(), + kGuardKey: kDay, + }); final sink = _Sink(); NotificationCenter.instance.presentSink = sink.call; diff --git a/test/ui_kit_new_widgets_test.dart b/test/ui_kit_new_widgets_test.dart index e40d1ed8..5a1e3664 100644 --- a/test/ui_kit_new_widgets_test.dart +++ b/test/ui_kit_new_widgets_test.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:openstrap_edge/models/metric.dart'; import 'package:openstrap_edge/ui/kit/skeleton.dart'; import 'package:openstrap_edge/ui/kit/state_card.dart'; import 'package:openstrap_edge/ui/kit/os_icons.dart'; @@ -80,22 +79,4 @@ void main() { await t.pump(const Duration(milliseconds: 500)); expect(find.text('x'), findsOneWidget); }); - - testWidgets('BaselineProgress.fromMetric parses need_baseline note', (t) async { - const m = Metric(note: 'need_baseline:have=2,need=5'); - final w = BaselineProgress.fromMetric(m, unlocks: 'to unlock Readiness'); - expect(w, isNotNull); - await t.pumpWidget(_host(w!)); - await t.pump(const Duration(milliseconds: 500)); - // remaining = 5 - 2 = 3 - expect(find.text('3'), findsOneWidget); - expect(find.text('nights to go'), findsOneWidget); - expect(find.text('to unlock Readiness'), findsOneWidget); - expect(find.text('2 of 5 nights'), findsOneWidget); - }); - - testWidgets('BaselineProgress.fromMetric returns null for non-baseline note', (t) async { - const m = Metric(note: 'something_else'); - expect(BaselineProgress.fromMetric(m), isNull); - }); } diff --git a/test/workout_reliability_test.dart b/test/workout_reliability_test.dart new file mode 100644 index 00000000..c3d07eb6 --- /dev/null +++ b/test/workout_reliability_test.dart @@ -0,0 +1,358 @@ +// Regressions for the "app closed mid-ride" class of failure. +// +// The live-workout path had several independent ways to lose a run or ride: +// heavy derivation firing an isolate mid-session, the display sleeping, and +// (platform-side) the foreground-service location type being stripped by an +// unrelated restart. These cover the parts that are testable in pure Dart — +// the two platform-channel behaviours are asserted at the seam. + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/derive_scheduler.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/gps/screen_wake.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/state/units_controller.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// Wait until [condition] holds, or give up after [timeout]. +/// +/// Returns as soon as the condition is met, so the generous timeout costs +/// nothing on a fast machine — it only buys headroom on a loaded CI runner. +/// Deliberately does NOT assert; the caller asserts afterwards so the failure +/// message names the real expectation rather than "timed out". +Future _until( + bool Function() condition, { + Duration timeout = const Duration(seconds: 5), +}) async { + final deadline = DateTime.now().add(timeout); + while (!condition() && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 10)); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // Releasing the gate re-arms the scheduler, which reads the durable + // compute_jobs queue — so this needs a real (in-memory-ish) DB. + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_workout_reliability_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + group('DeriveScheduler — live-workout gate', () { + late List logs; + late int runs; + late DeriveScheduler s; + + setUp(() { + logs = []; + runs = 0; + s = DeriveScheduler( + run: ({required DeriveJobKind kind}) async => runs++, + log: logs.add, + onChanged: () {}, + lightSettle: const Duration(milliseconds: 10), + heavySettle: const Duration(milliseconds: 10), + ); + }); + + tearDown(() => s.dispose()); + + test('holding is idempotent — repeated starts log once', () { + s.setWorkoutActive(true); + s.setWorkoutActive(true); + expect( + logs.where((l) => l.contains('workout live')).length, + 1, + reason: 'a re-entrant start must not re-log or re-arm', + ); + }); + + test('releasing after a hold logs the drain exactly once', () { + s.setWorkoutActive(true); + s.setWorkoutActive(false); + s.setWorkoutActive(false); + expect(logs.where((l) => l.contains('workout ended')).length, 1); + }); + + test('a release without a preceding hold is a no-op', () { + s.setWorkoutActive(false); + expect(logs, isEmpty); + }); + + test('the gate is visible in the snapshot', () { + expect(s.snapshot()['workout_active'], isFalse); + s.setWorkoutActive(true); + expect(s.snapshot()['workout_active'], isTrue); + s.setWorkoutActive(false); + expect(s.snapshot()['workout_active'], isFalse); + }); + + test( + 'a queued job stays parked for the session, then runs on release', + () async { + // This MUST enqueue real work. An earlier version asserted runs == 0 + // without queueing anything, so it passed even with the gate deleted — + // CodeRabbit caught it on the PR, and it was right. + s.setWorkoutActive(true); + s.markStoredData(); // enqueues a durable derive_light job + + // A fixed wait is correct HERE and only here: you cannot poll for + // "this never happens". Comfortably past the 10 ms settle. + await Future.delayed(const Duration(milliseconds: 150)); + expect(runs, 0, + reason: 'a queued job must not run while a workout is live'); + + s.setWorkoutActive(false); + // But the positive direction MUST poll. The drain does several DB + // round-trips, and a fixed 120 ms sleep here passed locally and failed + // on a slower CI runner — a flake I introduced in the previous commit. + await _until(() => runs == 1); + expect(runs, 1, + reason: 'and it must drain once the session ends, not be dropped'); + }, + ); + }); + + group('requeueComputeJob (the post-claim gate race)', () { + // `_drain()` clears the gate, then awaits takeNextComputeJob(). A workout + // starting inside that window leaves a job already marked `running` that + // must be handed back rather than run — otherwise it sits claimed until + // the next recoverComputeJobs(). + // + // Deliberately tests the PRIMITIVE rather than simulating the interleaving. + // Hitting that window means racing a real DB round-trip, which is a coin + // flip dressed up as a test — the kind that passes locally and fails on a + // loaded runner (this file already had one of those). What is worth + // pinning is the guarantee the drain path depends on: a claimed job comes + // back claimable, and being deferred does not burn an attempt. + setUp(() async { + // Leave no jobs behind from an earlier group. + for (var i = 0; i < 8; i++) { + final j = await LocalDb.takeNextComputeJob(); + if (j == null) break; + await LocalDb.completeComputeJob(j['id'].toString()); + } + }); + + test('a claimed job returns to the queue and stays runnable', () async { + await LocalDb.enqueueDeriveJob(type: 'derive_light', reason: 'test'); + + final claimed = await LocalDb.takeNextComputeJob(); + expect(claimed, isNotNull, reason: 'the job should be claimable'); + expect(claimed!['state'], 'running'); + final id = claimed['id'].toString(); + // NOTE: takeNextComputeJob returns the row as it was BEFORE its own + // update, so `attempts` here is the pre-increment value. That makes the + // comparison below the meaningful one: if the requeue failed to undo the + // increment, the second claim would report a higher number than the + // first. + final attemptsAtFirstClaim = (claimed['attempts'] as num).toInt(); + + // While claimed, nothing else can take it. + expect(await LocalDb.takeNextComputeJob(), isNull, + reason: 'a running job must not be handed out twice'); + + await LocalDb.requeueComputeJob(id); + + final again = await LocalDb.takeNextComputeJob(); + expect(again, isNotNull, + reason: 'a requeued job must be claimable again, not stranded'); + expect(again!['id'].toString(), id); + expect( + (again['attempts'] as num).toInt(), + attemptsAtFirstClaim, + reason: 'the requeue undid the increment, so the second claim starts ' + 'from the same count as the first — a deferral is not a retry', + ); + + await LocalDb.completeComputeJob(id); + expect(await LocalDb.takeNextComputeJob(), isNull); + }); + + test('requeueing an unknown id is harmless', () async { + await LocalDb.requeueComputeJob('no-such-job'); + expect(await LocalDb.takeNextComputeJob(), isNull); + }); + }); + + group('ScreenWake', () { + final calls = []; + + setUp(() { + calls.clear(); + ScreenWake.resetForTest(); + // Platform.isAndroid/isIOS are BOTH false on the host VM, so without this + // the dispatch short-circuits and these mocks are never reached — the + // call-count and failure assertions below asserted nothing at all. + ScreenWake.platformOverride = 'android'; + for (final ch in const [ + MethodChannel('openstrap/edge_tracking'), + MethodChannel('openstrap/ios_config'), + ]) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(ch, (c) async { + calls.add(c); + return true; + }); + } + }); + + test('enable then release round-trips the flag and hits the channel', + () async { + expect(ScreenWake.isHeld, isFalse); + await ScreenWake.enable(); + expect(ScreenWake.isHeld, isTrue); + expect(calls.single.method, 'keepAwake'); + expect((calls.single.arguments as Map)['on'], isTrue); + await ScreenWake.release(); + expect(ScreenWake.isHeld, isFalse); + expect((calls.last.arguments as Map)['on'], isFalse); + }); + + test('a platform that refuses does NOT latch, so a retry can succeed', + () async { + // Android answers false when no activity is attached. Latching the + // requested value there left Dart believing the screen was held and + // suppressed every later attempt. + var refuse = true; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('openstrap/edge_tracking'), + (c) async { + calls.add(c); + return !refuse; + }, + ); + await ScreenWake.enable(); + expect(ScreenWake.isHeld, isFalse, reason: 'refusal must not latch'); + + refuse = false; + await ScreenWake.enable(); + expect(ScreenWake.isHeld, isTrue, reason: 'the retry must go through'); + expect(calls.length, 2); + }); + + test( + 'repeated enables do not spam the platform channel', + () async { + await ScreenWake.enable(); + final afterFirst = calls.length; + await ScreenWake.enable(); + await ScreenWake.enable(); + expect( + calls.length, + afterFirst, + reason: 'the 1 Hz session tick must not hit the channel every second', + ); + }, + ); + + test('a release fired while an enable is in flight still wins', () async { + // `_on` only updates AFTER the platform await, so a release arriving + // mid-enable used to read the stale `false`, decide it had nothing to do, + // and return — then the in-flight enable latched true and the display + // stayed held for the rest of the app's life. Both call sites in + // AppState are fire-and-forget, so starting a workout and immediately + // stopping it was enough to hit this. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('openstrap/edge_tracking'), + (c) async { + calls.add(c); + // A real channel hop is not instantaneous; this is the window the + // race lived in. + await Future.delayed(const Duration(milliseconds: 20)); + return true; + }, + ); + + await Future.wait([ScreenWake.enable(), ScreenWake.release()]); + + expect(ScreenWake.isHeld, isFalse, + reason: 'the release must win — the screen cannot stay held'); + expect( + [for (final c in calls) (c.arguments as Map)['on']], + [true, false], + reason: 'both transitions must reach the platform, in order', + ); + }); + + test('a release with nothing held is a no-op', () async { + await ScreenWake.release(); + expect(calls, isEmpty); + }); + + test('a channel failure never throws into the workout path', () async { + for (final ch in const [ + MethodChannel('openstrap/edge_tracking'), + MethodChannel('openstrap/ios_config'), + ]) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + ch, (c) async => throw PlatformException(code: 'boom')); + } + // Losing the wake flag degrades to "the screen sleeps" — it must never + // propagate and interrupt a session. + await expectLater(ScreenWake.enable(), completes); + expect(ScreenWake.isHeld, isFalse, + reason: 'a throwing channel must not latch either'); + }); + }); + + group('live milestones', () { + test( + 'a milestone fires once per SESSION, surviving screen re-entry', + () { + // The live screen is disposed and rebuilt every time the athlete + // navigates away and back. The dedup set therefore lives on the + // workout, not the screen — a screen-local set re-fired "5 MINUTES" + // (banner + haptic + confetti) on every single return. + final w = LiveWorkoutState( + startTime: DateTime.now().subtract(const Duration(minutes: 6)), + targetKcal: 300, + workoutId: 'w1', + type: 'run', + ); + expect(w.firedMilestones.add('t5'), isTrue, reason: 'first announce'); + expect(w.firedMilestones.add('t5'), isFalse, + reason: 're-entering the screen must not re-fire it'); + // A genuinely new milestone still gets through. + expect(w.firedMilestones.add('t10'), isTrue); + }, + ); + }); + + group('pace is MOVING pace', () { + final units = UnitsController.seed(UnitSystem.metric); + + test( + 'standing still after a short walk does not invent an absurd pace', + () { + // The reported bug: ~250 m covered, then a long stationary spell. + // Averaging over ELAPSED time produced "40:32 /km" for someone who had + // barely moved. Over MOVING time it is a real walking pace. + const meters = 250.0; + const movingSec = 200; // ~3.6 km/h — a slow walk + const elapsedSec = 608; // most of it spent standing + + expect( + units.pace(meters, elapsedSec), + '40:32 /km', + reason: 'this is the wrong number the old code showed', + ); + expect(units.pace(meters, movingSec), '13:20 /km'); + }, + ); + + test('no moving time yet reports "—" rather than dividing by elapsed', () { + expect(units.pace(120.0, 0), '—'); + }); + }); +} diff --git a/test/workout_share_card_test.dart b/test/workout_share_card_test.dart new file mode 100644 index 00000000..cd9366f1 --- /dev/null +++ b/test/workout_share_card_test.dart @@ -0,0 +1,280 @@ +// The share card is defined as much by what it LEAVES OUT as by what it shows. +// +// Sharing used to rasterise the whole finish card — header, route thumbnail, +// strain gauge, peak/avg/kcal/steps, time-in-zones, the HR-recovery curve and +// any PR badges — into one tall PNG. These tests pin the composition that +// replaced it: map-led, one headline figure, three supporting stats, and none +// of the dashboard furniture that doesn't survive a feed thumbnail. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:openstrap_edge/gps/route_models.dart'; +import 'package:openstrap_edge/state/units_controller.dart'; +import 'package:openstrap_edge/theme/theme.dart'; +import 'package:openstrap_edge/theme/tokens.dart'; +import 'package:openstrap_edge/ui/activity/workout_share_card.dart'; +import 'package:openstrap_edge/ui/design/fake_route_fixture.dart'; +import 'package:openstrap_edge/ui/kit/route_map.dart'; +import 'package:provider/provider.dart'; + +List _route() => [ + for (var i = 0; i < 40; i++) + RouteVertex( + LatLng(51.5074 + i * 0.0004, -0.1278 + i * 0.0003), + (i ~/ 8).clamp(0, 5), + ), + ]; + +WorkoutShareData _data({List? vertices}) => WorkoutShareData( + title: 'Morning Run', + subtitle: '27 Jul 2026', + vertices: vertices ?? _route(), + heroValue: '8.42', + heroUnit: 'km', + stats: const [ + ('42:30', 'Time'), + ('5:02 /km', 'Pace'), + ('14.2', 'Strain'), + ], + accent: AppColors.coral, + ); + +Widget _host(Widget child) => MultiProvider( + providers: [ + ChangeNotifierProvider.value( + value: UnitsController.seed(UnitSystem.metric), + ), + ], + child: MaterialApp( + theme: buildOpenStrapTheme(kDarkPalette), + home: Scaffold(body: Center(child: child)), + ), + ); + +/// Drop map-tile fetch errors, keep everything else. +/// +/// flutter_test's HTTP mock answers 400 for every request, so each tile throws +/// through the image-resource service — and flutter_test fails a test on ANY +/// unhandled exception, so a card that renders perfectly still goes red. +/// +/// Filtered rather than drained, so RenderFlex overflow still fails these +/// tests. MUST be installed inside the test body: the test binding replaces +/// FlutterError.onError after setUp runs, so a filter installed there is +/// silently discarded (which is what sent me down a blind alley of +/// hand-stubbing HttpClient before noticing). +void _ignoreTileFetchErrors() { + final previous = FlutterError.onError; + FlutterError.onError = (details) { + if (details.library == 'image resource service') return; + if (details.exception.toString().contains('ClientException')) return; + previous?.call(details); + }; + addTearDown(() => FlutterError.onError = previous); +} + +void main() { + setUp(() => AppColors.active = kDarkPalette); + tearDown(() => AppColors.active = kLightPalette); + + group('WorkoutShareCard', () { + testWidgets('leads with the map and the headline figure', (t) async { + _ignoreTileFetchErrors(); + t.view.physicalSize = const Size(500, 900); + t.view.devicePixelRatio = 1.0; + addTearDown(t.view.reset); + + await t.pumpWidget(_host( + WorkoutShareCard(data: _data(), format: ShareFormat.feed), + )); + await t.pump(const Duration(milliseconds: 400)); + + expect(find.byType(RouteMapView), findsOneWidget, + reason: 'the route is the reason anyone shares this'); + expect(find.text('8.42'), findsOneWidget); + expect(find.text('KM'), findsOneWidget); + expect(find.text('MORNING RUN'), findsOneWidget); + expect(t.takeException(), isNull); + }); + + testWidgets('carries exactly three supporting stats, no more', (t) async { + _ignoreTileFetchErrors(); + t.view.physicalSize = const Size(500, 900); + t.view.devicePixelRatio = 1.0; + addTearDown(t.view.reset); + + await t.pumpWidget(_host( + WorkoutShareCard(data: _data(), format: ShareFormat.feed), + )); + await t.pump(const Duration(milliseconds: 400)); + + for (final label in ['TIME', 'PACE', 'STRAIN']) { + expect(find.text(label), findsOneWidget); + } + // The dashboard furniture that used to end up in the shared PNG. + for (final absent in ['PEAK BPM', 'TIME IN ZONES', 'KCAL', 'STEPS']) { + expect(find.text(absent), findsNothing, + reason: '"$absent" belongs on the screen, not in a feed post'); + } + expect(t.takeException(), isNull); + }); + + testWidgets('both formats lay out without overflowing', (t) async { + _ignoreTileFetchErrors(); + t.view.physicalSize = const Size(500, 1400); + t.view.devicePixelRatio = 1.0; + addTearDown(t.view.reset); + + for (final f in ShareFormat.values) { + await t.pumpWidget(_host(WorkoutShareCard(data: _data(), format: f))); + await t.pump(const Duration(milliseconds: 400)); + final size = t.getSize(find.byType(WorkoutShareCard)); + expect(size.width, WorkoutShareCard.kWidth); + expect( + size.height, + closeTo(WorkoutShareCard.kWidth / f.aspect, 0.5), + reason: '${f.label} must honour its aspect ratio', + ); + expect(t.takeException(), isNull); + } + }); + + testWidgets('an indoor workout keeps the same composition, no map', + (t) async { + t.view.physicalSize = const Size(500, 900); + t.view.devicePixelRatio = 1.0; + addTearDown(t.view.reset); + + final indoor = WorkoutShareData( + title: 'Strength', + subtitle: '27 Jul 2026', + vertices: const [], + heroValue: '48:10', + heroUnit: '', + stats: const [('14.2', 'Strain'), ('512', 'Kcal'), ('141', 'Avg bpm')], + accent: AppColors.coral, + ); + + await t.pumpWidget(_host( + WorkoutShareCard(data: indoor, format: ShareFormat.feed), + )); + await t.pump(const Duration(milliseconds: 400)); + + expect(find.byType(RouteMapView), findsNothing); + // Same layout, different backdrop — not a second design. + expect(find.text('48:10'), findsOneWidget); + expect(find.text('STRAIN'), findsOneWidget); + expect(find.text('AVG BPM'), findsOneWidget); + expect(t.takeException(), isNull); + }); + + testWidgets('a one-point route is treated as no route', (t) async { + t.view.physicalSize = const Size(500, 900); + t.view.devicePixelRatio = 1.0; + addTearDown(t.view.reset); + + // A single fix cannot draw a line; RouteMapView would render an empty + // box, so the card must fall back rather than show a blank frame. + final oneFix = _data(vertices: [ + RouteVertex(const LatLng(51.5074, -0.1278), 2), + ]); + expect(oneFix.hasRoute, isFalse); + + await t.pumpWidget(_host( + WorkoutShareCard(data: oneFix, format: ShareFormat.feed), + )); + await t.pump(const Duration(milliseconds: 400)); + expect(find.byType(RouteMapView), findsNothing); + expect(t.takeException(), isNull); + }); + }); + + group('buildWorkoutShareData — one composition, two entry points', () { + final units = UnitsController.seed(UnitSystem.metric); + final route = fakeRunRoute(); + final when = DateTime(2026, 7, 27, 8, 14); + + WorkoutShareData build({WorkoutRoute? r}) => buildWorkoutShareData( + units: units, + type: 'run', + duration: const Duration(minutes: 20, seconds: 6), + when: when, + maxHr: 190, + strain: 11.6, + calories: 284, + route: r, + avgHr: 148, + ); + + test('the finish screen and the detail screen produce the SAME card', () { + // Both call this factory with the same workout. If they ever diverge, + // sharing the same run from two places gives two different images. + final fromFinish = build(r: route); + final fromDetail = build(r: route); + expect(fromDetail.title, fromFinish.title); + expect(fromDetail.subtitle, fromFinish.subtitle); + expect(fromDetail.heroValue, fromFinish.heroValue); + expect(fromDetail.heroUnit, fromFinish.heroUnit); + expect(fromDetail.stats, fromFinish.stats); + expect(fromDetail.vertices.length, fromFinish.vertices.length); + }); + + test('a route leads with DISTANCE — the map is what the image shows', () { + final d = build(r: route); + expect(d.hasRoute, isTrue); + expect(d.heroUnit, 'km'); + expect(double.tryParse(d.heroValue), isNotNull); + expect([for (final (_, l) in d.stats) l], ['Time', 'Pace', 'Strain']); + }); + + test('no route leads with the CLOCK and swaps to indoor stats', () { + final d = build(); + expect(d.hasRoute, isFalse); + expect(d.heroValue, '20m 06s'); + expect(d.heroUnit, isEmpty); + expect([for (final (_, l) in d.stats) l], ['Strain', 'Kcal', 'Avg bpm']); + expect(d.stats.last.$1, '148'); + }); + + test('an absent average heart rate is "—", never a fabricated 0', () { + final d = buildWorkoutShareData( + units: units, + type: 'other', + duration: const Duration(minutes: 30), + when: when, + maxHr: 190, + strain: 8.0, + calories: 200, + avgHr: null, + ); + expect(d.stats.last, ('—', 'Avg bpm')); + }); + + test('imperial units carry through to the headline', () { + final d = buildWorkoutShareData( + units: UnitsController.seed(UnitSystem.imperial), + type: 'run', + duration: const Duration(minutes: 20), + when: when, + maxHr: 190, + strain: 11.6, + calories: 284, + route: route, + ); + expect(d.heroUnit, 'mi'); + }); + + test('an empty type still gets a human title', () { + final d = buildWorkoutShareData( + units: units, + type: '', + duration: const Duration(minutes: 5), + when: when, + maxHr: 190, + strain: 1.0, + calories: 20, + ); + expect(d.title, 'Workout'); + }); + }); +} diff --git a/test/workout_sleep_redesign_test.dart b/test/workout_sleep_redesign_test.dart index bfbd3d60..7d2bb6bf 100644 --- a/test/workout_sleep_redesign_test.dart +++ b/test/workout_sleep_redesign_test.dart @@ -365,7 +365,9 @@ void main() { expect(find.text('PEAK BPM'), findsOneWidget); expect(find.text('TIME IN ZONES'), findsOneWidget); expect(find.text('Full breakdown'), findsOneWidget); - expect(find.text('Share'), findsOneWidget); + // Share is the PRIMARY action now (filled, full-width), and it + // opens a composed preview rather than rasterising this screen. + expect(find.text('Share workout'), findsOneWidget); expect(t.takeException(), isNull); await t.pump(const Duration(milliseconds: 1600)); // settle shimmers } @@ -419,7 +421,9 @@ void main() { // TestWidgetsFlutterBinding, a sandboxing limitation, not a product // bug. Verify the actual share output manually on a real // device/simulator via the Design Gallery's "Workout preview" section. - expect(find.text('Share'), findsOneWidget); + // Share is the PRIMARY action now (filled, full-width), and it + // opens a composed preview rather than rasterising this screen. + expect(find.text('Share workout'), findsOneWidget); }); }); } diff --git a/test/zone_contrast_test.dart b/test/zone_contrast_test.dart new file mode 100644 index 00000000..22d81725 --- /dev/null +++ b/test/zone_contrast_test.dart @@ -0,0 +1,88 @@ +// Legibility guard for the HR-zone ramp on the live session screen. +// +// The live workout screen paints on AppColors.night/nightAlt regardless of the +// user's theme, so its zone colours cannot come from the ACTIVE palette. Two +// real bugs came out of that: +// +// 1. In light mode the ramp handed back hues tuned for a white background +// and painted them on near-black. +// 2. Even on the dark palette, Z0 mapped to `cool` — a SURFACE token, not an +// ink. As a foreground it measured 1.03:1 against nightAlt: invisible. +// Z0 is the RESTING zone, so it is what is on screen at the start of +// every workout and whenever heart rate is low or absent. +// +// These assert the fix numerically rather than by eye, so a palette edit can't +// silently reintroduce an unreadable zone. + +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/theme/tokens.dart'; + +/// WCAG 2.1 relative luminance. +double _luminance(Color c) { + double channel(double v) { + final s = v / 255.0; + return s <= 0.03928 ? s / 12.92 : math.pow((s + 0.055) / 1.055, 2.4) as double; + } + + return 0.2126 * channel((c.r * 255).roundToDouble()) + + 0.7152 * channel((c.g * 255).roundToDouble()) + + 0.0722 * channel((c.b * 255).roundToDouble()); +} + +/// WCAG 2.1 contrast ratio, 1.0 (identical) … 21.0 (black on white). +double _contrast(Color a, Color b) { + final la = _luminance(a); + final lb = _luminance(b); + final hi = math.max(la, lb); + final lo = math.min(la, lb); + return (hi + 0.05) / (lo + 0.05); +} + +void main() { + // 3:1 is the WCAG AA floor for large text and non-text UI components, which + // is what these are: big tabular figures and coloured indicators. + const minRatio = 3.0; + + group('zoneOnDark is legible on the session surface', () { + for (final surface in { + 'night': AppColors.night, + 'nightAlt': AppColors.nightAlt, + }.entries) { + for (var z = 0; z <= 5; z++) { + test('Z$z on ${surface.key} clears $minRatio:1', () { + final ratio = _contrast(AppColors.zoneOnDark(z), surface.value); + expect( + ratio, + greaterThanOrEqualTo(minRatio), + reason: 'Z$z renders at ${ratio.toStringAsFixed(2)}:1 on ' + '${surface.key} — unreadable. Zone colours on the live ' + 'session screen must be inks, not surface tokens.', + ); + }); + } + } + + test('Z0 specifically — the regression that shipped', () { + // `cool` is what Z0 used to resolve to. Pin the old value as a failing + // reference so the intent of the fix stays legible. + final broken = _contrast(kDarkPalette.cool, AppColors.nightAlt); + final fixed = _contrast(AppColors.zoneOnDark(0), AppColors.nightAlt); + expect(broken, lessThan(1.5), reason: 'the old Z0 was invisible'); + expect(fixed, greaterThan(6.0), reason: 'the new Z0 is clearly legible'); + }); + }); + + test('the ramp is independent of the ACTIVE palette', () { + // The live screen is always dark; flipping the app theme must not change + // a single zone colour on it. + AppColors.active = kLightPalette; + final inLight = [for (var z = 0; z <= 5; z++) AppColors.zoneOnDark(z)]; + AppColors.active = kDarkPalette; + final inDark = [for (var z = 0; z <= 5; z++) AppColors.zoneOnDark(z)]; + addTearDown(() => AppColors.active = kLightPalette); + expect(inLight, equals(inDark)); + }); +}