From 7ab2701ba8d86d92d27d75dd7eb703d612a79be2 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 13 Jun 2026 16:38:00 +0530 Subject: [PATCH 01/55] Add backend URL to README Added deployed backend URL to the README. --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4791893..e6e536d 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,8 @@ with your health data except make the decoders and the analytics better over tim selling it and I'm not interested in it. But you don't have to trust me on that, that's what the self-host option is for. +## Already Deployed Backend URL : https://openstrap-backend.abdulsaheel81.workers.dev + ## The stack, briefly `flutter_blue_plus` for Bluetooth, `sqflite` for the local store, `http` and `provider` @@ -174,4 +176,4 @@ and `shared_preferences` for the plumbing, `workmanager` for the background sync `share_plus` for the look of it. -# Please raise Fixes, Lets make it better together \ No newline at end of file +# Please raise Fixes, Lets make it better together From 2684ff3646a5f981a79f7b201531fd7dcea1bbe0 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 13 Jun 2026 18:49:32 +0530 Subject: [PATCH 02/55] Implement Edge Tracking foreground service for background BLE connection --- android/app/src/main/AndroidManifest.xml | 10 +++ .../openstrap_edge/EdgeTrackingService.kt | 67 +++++++++++++++++++ .../openstrap/openstrap_edge/MainActivity.kt | 31 ++++++++- lib/app.dart | 6 +- lib/state/app_state.dart | 27 +++++++- lib/sync/edge_tracking.dart | 36 ++++++++++ 6 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/EdgeTrackingService.kt create mode 100644 lib/sync/edge_tracking.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4333f36..fd9b416 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -10,6 +10,10 @@ + + + + + + + = Build.VERSION_CODES.Q) { + startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE) + } else { + startForeground(NOTIF_ID, notif) + } + return START_NOT_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun createChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val ch = NotificationChannel( + CHANNEL_ID, + "Edge Tracking", + NotificationManager.IMPORTANCE_LOW, + ) + ch.description = "Keeps your strap syncing in the background" + ch.setShowBadge(false) + (getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) + .createNotificationChannel(ch) + } + } + + private fun buildNotification(): Notification { + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Edge Tracking") + .setContentText("Keeping your strap in sync") + .setSmallIcon(android.R.drawable.stat_sys_data_bluetooth) + .setOngoing(true) + .setSilent(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + } +} diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/MainActivity.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/MainActivity.kt index 5e05c14..72ae7ff 100644 --- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/MainActivity.kt +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/MainActivity.kt @@ -1,5 +1,34 @@ package wtf.openstrap.openstrap_edge +import android.content.Intent +import android.os.Build import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel -class MainActivity : FlutterActivity() +class MainActivity : FlutterActivity() { + private val edgeTrackingChannel = "openstrap/edge_tracking" + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, edgeTrackingChannel) + .setMethodCallHandler { call, result -> + when (call.method) { + "start" -> { + val intent = Intent(this, EdgeTrackingService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(intent) + } else { + startService(intent) + } + result.success(null) + } + "stop" -> { + stopService(Intent(this, EdgeTrackingService::class.java)) + result.success(null) + } + else -> result.notImplemented() + } + } + } +} diff --git a/lib/app.dart b/lib/app.dart index 1f9bf0f..4500cb7 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -35,10 +35,14 @@ class _OpenStrapAppState extends State with WidgetsBindingObserver @override void didChangeAppLifecycleState(AppLifecycleState state) { + final app = context.read(); if (state == AppLifecycleState.resumed) { - final app = context.read(); app.maybeFinishFromLiveActivity(); if (app.isAuthenticated && app.isPaired) app.openSession(); + } else if (state == AppLifecycleState.paused) { + // Backgrounded: hand the band to the iOS restore path so it can wake-and-drain + // in the background (no-op on Android, where the foreground service holds it). + app.pauseForBackground(); } } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 72a8d4a..1db9bf3 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -8,6 +8,7 @@ // else → main Shell (auto-connect saved band, drain, live, upload) import 'dart:async'; +import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; @@ -21,6 +22,7 @@ import '../net/api_client.dart'; import '../live/live_activity.dart'; import '../sync/background_sync.dart'; import '../sync/config.dart'; +import '../sync/edge_tracking.dart'; import '../widget/widget_service.dart'; import '../sync/file_log.dart'; import '../sync/uploader.dart'; @@ -150,12 +152,30 @@ class AppState extends ChangeNotifier { _keepAlive = false; IosBleRestore.foregroundActive = false; await BackgroundSync.disable(); + await EdgeTracking.stop(); await IosBleRestore.disarm(); await engine.disconnect(); await session!.clear(); notifyListeners(); } + /// Called when the app goes to the background. On iOS this hands the band to the + /// CoreBluetooth restoration path: clear the foreground flag (the wake-drain is gated + /// on it) and release our live connection so the native restore central's no-timeout + /// pending connect can hold it and relaunch us when the band is reachable. Without + /// this, `foregroundActive` stays true for the whole session and the background wake + /// never drains — sync only ever happened on app open. + /// + /// On Android we do NOT disconnect: the Edge Tracking foreground service keeps the + /// process + connection alive, so the live drain just continues. + Future pauseForBackground() async { + if (!Platform.isIOS) return; + IosBleRestore.foregroundActive = false; + _keepAlive = false; // stop the drop handler from auto-reconnecting and fighting the handoff + await engine.disconnect(); + _log('Backgrounded — released band to iOS restore for background sync'); + } + Future _onRecord(Sample? sample, RawRecord raw) async { await LocalDb.insertRecord(raw, sample); } @@ -192,6 +212,7 @@ class AppState extends ChangeNotifier { _keepAlive = false; IosBleRestore.foregroundActive = false; await BackgroundSync.disable(); + await EdgeTracking.stop(); await IosBleRestore.disarm(); await engine.disconnect(); await PairedDevice.clear(); @@ -243,9 +264,11 @@ class AppState extends ChangeNotifier { _setBusy(true); lastError = null; _keepAlive = true; - // Keep syncing in the background even when the app isn't open (no foreground - // service / notification). Idempotent — safe to call on every session start. + // Keep syncing in the background. Idempotent — safe to call on every session start. BackgroundSync.enable(); + // Android: start the Edge Tracking foreground service so the live connection keeps + // draining while backgrounded (Android kills background processes otherwise). + EdgeTracking.start(); // iOS: arm CoreBluetooth restoration so the band can relaunch us when terminated. // The foreground guard stops a wake from fighting this live session for the band. IosBleRestore.foregroundActive = true; diff --git a/lib/sync/edge_tracking.dart b/lib/sync/edge_tracking.dart new file mode 100644 index 0000000..89b7d30 --- /dev/null +++ b/lib/sync/edge_tracking.dart @@ -0,0 +1,36 @@ +// Android "Edge Tracking" foreground service. +// +// Keeps the app process alive while backgrounded so the live BLE connection keeps +// draining the strap (Android kills background processes otherwise). Shows a single +// silent, low-priority notification ("Edge Tracking"), the same trade modern Android +// requires for long-running BLE — there is no reliable background-BLE path without it. +// +// No-op on iOS: there, CoreBluetooth state restoration (BleRestoreManager) handles +// background relaunch silently, so no service/notification is needed. + +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +class EdgeTracking { + static const _ch = MethodChannel('openstrap/edge_tracking'); + + /// Start the foreground service. Idempotent — safe to call on every session start. + static Future start() async { + if (!Platform.isAndroid) return; + try { + await _ch.invokeMethod('start'); + } catch (e) { + debugPrint('[edge-tracking] start failed: $e'); + } + } + + /// Stop the service (sign-out / unpair). + static Future stop() async { + if (!Platform.isAndroid) return; + try { + await _ch.invokeMethod('stop'); + } catch (_) {} + } +} From d2471a6ce36e643f8d3f31800a2c842a616f501b Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 13 Jun 2026 20:39:33 +0530 Subject: [PATCH 03/55] Fix background sync: hold BLE connection alive + persistent flusher + honest upload count iOS only ever synced on app-open: pauseForBackground disconnected the band, so iOS dropped the Bluetooth assertion and suspended the app in ~34s, and the restore central never armed. Now we KEEP the live connection in the background (iOS resumes us per BLE notification under bluetooth-central) so the drain + upload continue. - Add one connection-independent flusher (~15s) that uploads queued records and retries anything a prior tick failed to send (uploader retains rows on any non-200). Removes the per-record flushing that was tripping the backend rate limit. No cooldown. - Show the honest uploaded count: read `received` from the ingest response (the client was reading a non-existent `processed` field, so it always showed 0/N). - BleRestoreManager is recovery-only: event-driven re-arm via setOwnsBand, and the 50-min cooldown timer is gone (replaced by an idle-after-sync flag). - Remove the 15-min WorkManager periodic task (iOS + Android) and all its plumbing; Android keep-alive is the Edge Tracking foreground service, iOS is the kept-alive connection with CB state-restoration as recovery only. - Ensure the flusher + Android foreground service start on every session-establish path (init/login/reconnect/resume), not just openSession. - Cleanup: drop the workmanager dependency, the BGTask/processing/fetch Info.plist entries, and RECEIVE_BOOT_COMPLETED. --- android/app/src/main/AndroidManifest.xml | 1 - ios/Runner/AppDelegate.swift | 14 +-- ios/Runner/BleRestoreManager.swift | 97 ++++++++++------- ios/Runner/Info.plist | 8 -- lib/ble/ios_ble_restore.dart | 14 ++- lib/main.dart | 4 - lib/state/app_state.dart | 133 +++++++++++++++++++---- lib/sync/background_sync.dart | 59 ++-------- lib/sync/uploader.dart | 7 +- pubspec.lock | 32 ------ pubspec.yaml | 4 - 11 files changed, 202 insertions(+), 171 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fd9b416..a5ae36d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,7 +9,6 @@ android:maxSdkVersion="30" /> - diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index bce4f79..a85e997 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,7 +1,5 @@ import Flutter import UIKit -// workmanager 0.9 split the iOS plugin into the `workmanager_apple` module. -import workmanager_apple @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { @@ -9,15 +7,9 @@ import workmanager_apple _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - // Background sync (workmanager / BGTaskScheduler). - WorkmanagerPlugin.setPluginRegistrantCallback { registry in - GeneratedPluginRegistrant.register(with: registry) - } - // workmanager 0.9 API. Identifier must match BGTaskSchedulerPermittedIdentifiers. - WorkmanagerPlugin.registerPeriodicTask( - withIdentifier: "openstrap.periodicSync", - frequency: NSNumber(value: 15 * 60) - ) + // No OS periodic background task (no WorkManager 15-min / BGTask): continuous sync is + // the kept-alive live BLE connection + the persistent flusher in AppState, with + // CoreBluetooth state restoration below as the relaunch-recovery fallback. // CoreBluetooth state restoration — must be created here (early) so iOS can relaunch // us with willRestoreState when the band reappears. Wakes the app → headless sync. diff --git a/ios/Runner/BleRestoreManager.swift b/ios/Runner/BleRestoreManager.swift index 276b5b2..22d1fa2 100644 --- a/ios/Runner/BleRestoreManager.swift +++ b/ios/Runner/BleRestoreManager.swift @@ -13,18 +13,19 @@ import Flutter /// relaunches us when the band shows up, then it cancels its own connection and tells /// Flutter to run the normal headless sync. /// -/// Pacing: a no-timeout pending connect to an in-range band fires immediately, so left -/// alone it would reconnect-drain in a loop. After each sync we enter a cooldown -/// (~50 min) before re-arming, which lands the cadence near "once an hour" while the -/// band is around, and still syncs promptly when the band returns after being away. -/// Arming only happens while backgrounded; in the foreground flutter_blue_plus owns the -/// band. +/// This is RECOVERY-ONLY: normal sync is the kept-alive live connection + the AppState +/// flusher. The restore central arms a no-timeout pending connect ONLY when Dart tells +/// it the connection dropped (`setOwnsBand(false)` / `arm`). No timers, no cooldown. +/// +/// Loop prevention is event-driven, not time-based: after a wake hands off to Dart and +/// Dart reports the drain done (`syncDone`), we go IDLE and do NOT re-arm. We re-arm only +/// on the next explicit request from Dart (a fresh disconnect). Arming only happens while +/// backgrounded; in the foreground flutter_blue_plus owns the band. class BleRestoreManager: NSObject { static let shared = BleRestoreManager() private static let restoreId = "openstrap.ble.restore" private static let bandUUIDKey = "openstrap.ble.band_uuid" - private let cooldownSeconds: TimeInterval = 50 * 60 private var central: CBCentralManager? private var bandUUID: UUID? @@ -33,9 +34,15 @@ class BleRestoreManager: NSObject { private var flutterReady = false private var wakeQueuedBeforeReady = false private var handedOff = false // true between wake → Dart's syncDone - private var coolingDown = false - private var cooldownWork: DispatchWorkItem? + /// Set after a wake's sync completes; suppresses re-arming until Dart explicitly + /// re-arms on the next disconnect. Replaces the old time-based cooldown — no loop, + /// no timer. + private var idleAfterSync = false private var bgTask: UIBackgroundTaskIdentifier = .invalid + /// True while the app holds the live flutter_blue_plus connection (foreground OR + /// backgrounded-but-connected). The restore central must not arm a competing connect + /// to the same peripheral while this is true. + private var appOwnsBand = false // MARK: - Lifecycle @@ -69,6 +76,21 @@ class BleRestoreManager: NSObject { self.saveBandUUID(uuid) self.bandUUID = uuid self.handedOff = false + self.idleAfterSync = false // explicit (re-)arm request from Dart + self.armIfAppropriate() + } + result(nil) + case "setOwnsBand": + let owns = (call.arguments as? Bool) ?? false + self.appOwnsBand = owns + if owns { + // App reclaimed the band — drop our pending connect so the two centrals don't fight. + self.cancelPending() + NSLog("[ble-restore] app owns band — pending connect cancelled") + } else { + // App released the band (connection dropped in background) — arm recovery. + self.idleAfterSync = false // explicit re-arm request from Dart + NSLog("[ble-restore] app released band — arming recovery") self.armIfAppropriate() } result(nil) @@ -83,10 +105,12 @@ class BleRestoreManager: NSObject { } result(nil) case "syncDone": - // Dart finished the headless drain — pace the next one via cooldown. + // Dart finished the headless drain. Go idle (no re-arm) until the next explicit + // arm from Dart — prevents a reconnect-drain loop with no timer/cooldown. self.handedOff = false + self.idleAfterSync = true + self.cancelPending() self.endBackground() - self.beginCooldown() result(nil) default: result(FlutterMethodNotImplemented) @@ -104,11 +128,21 @@ class BleRestoreManager: NSObject { // MARK: - Pending connect private func armIfAppropriate() { - guard !handedOff, !coolingDown, - let central = central, central.state == .poweredOn, - let uuid = bandUUID else { return } + // The app holds the live connection — don't arm a competing connect. + if appOwnsBand { NSLog("[ble-restore] skip arm — app owns band"); return } + // Log every guard-fail reason: an unexplained no-arm is why background relaunch + // silently never happened (the band reappeared but nothing was pending to wake us). + guard !handedOff else { NSLog("[ble-restore] skip arm — handedOff"); return } + guard !idleAfterSync else { NSLog("[ble-restore] skip arm — idle after sync (awaiting re-arm)"); return } + guard let central = central else { NSLog("[ble-restore] skip arm — no central"); return } + guard central.state == .poweredOn else { + NSLog("[ble-restore] skip arm — central not poweredOn (state=\(central.state.rawValue))"); return + } + guard let uuid = bandUUID else { NSLog("[ble-restore] skip arm — no bandUUID"); return } // In the foreground, flutter_blue_plus owns the band — don't compete. - if UIApplication.shared.applicationState == .active { return } + if UIApplication.shared.applicationState == .active { + NSLog("[ble-restore] skip arm — app active"); return + } guard let p = central.retrievePeripherals(withIdentifiers: [uuid]).first else { NSLog("[ble-restore] band not retrievable yet") return @@ -123,23 +157,8 @@ class BleRestoreManager: NSObject { pending = nil } - private func beginCooldown() { - coolingDown = true - cancelPending() - cooldownWork?.cancel() - let work = DispatchWorkItem { [weak self] in - guard let self = self else { return } - self.coolingDown = false - self.armIfAppropriate() - } - cooldownWork = work - DispatchQueue.main.asyncAfter(deadline: .now() + cooldownSeconds, execute: work) - NSLog("[ble-restore] cooldown \(Int(cooldownSeconds))s before next arm") - } - private func disarm() { - cooldownWork?.cancel() - coolingDown = false + idleAfterSync = false handedOff = false cancelPending() clearBandUUID() @@ -158,14 +177,16 @@ class BleRestoreManager: NSObject { wakeQueuedBeforeReady = true NSLog("[ble-restore] wake queued (Flutter not ready)") } - // Safety net: if Dart never calls syncDone (crash/timeout), cooldown anyway so we - // neither loop nor get stuck. + // Watchdog: if Dart never calls syncDone (crash), clear the handoff so we don't get + // stuck, and go idle (await an explicit re-arm) so we don't loop. Not a sync cadence — + // just a failsafe to release the in-flight state. DispatchQueue.main.asyncAfter(deadline: .now() + 60) { [weak self] in guard let self = self, self.handedOff else { return } - NSLog("[ble-restore] syncDone timeout — cooling down") + NSLog("[ble-restore] syncDone watchdog fired — releasing handoff, going idle") self.handedOff = false + self.idleAfterSync = true + self.cancelPending() self.endBackground() - self.beginCooldown() } } @@ -219,11 +240,13 @@ extension BleRestoreManager: CBCentralManagerDelegate { func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { NSLog("[ble-restore] didDisconnect (handedOff=\(handedOff))") - if !handedOff && !coolingDown { armIfAppropriate() } + // Re-arm only if our own pending connect dropped while still in recovery mode (band + // went away again). armIfAppropriate's idleAfterSync/appOwnsBand guards prevent loops. + if !handedOff { armIfAppropriate() } } func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { NSLog("[ble-restore] didFailToConnect: \(error?.localizedDescription ?? "—")") - if !handedOff && !coolingDown { armIfAppropriate() } + if !handedOff { armIfAppropriate() } } } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 7fd1277..2a833b3 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -4,11 +4,6 @@ NSSupportsLiveActivities - BGTaskSchedulerPermittedIdentifiers - - openstrap.periodicSync - be.tramckrijte.workmanager.BackgroundTask - CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion @@ -63,9 +58,6 @@ UIBackgroundModes bluetooth-central - processing - fetch - bluetooth-peripheral remote-notification UILaunchStoryboardName diff --git a/lib/ble/ios_ble_restore.dart b/lib/ble/ios_ble_restore.dart index 77e291e..dd49990 100644 --- a/lib/ble/ios_ble_restore.dart +++ b/lib/ble/ios_ble_restore.dart @@ -5,7 +5,8 @@ // even from terminated. When that fires, native invokes `wake` here and we run the same // headless drain the periodic task uses, then tell native we're done so it re-arms. // -// No-op on Android (WorkManager handles background there). +// No-op on Android (the Edge Tracking foreground service keeps the process + live +// connection alive there — no restore central needed). import 'dart:io'; @@ -49,6 +50,17 @@ class IosBleRestore { } catch (_) {} } + /// Tell native whether the app currently owns the live connection (via + /// flutter_blue_plus). While true, the restore central must NOT arm a competing + /// pending connect to the same peripheral. Set false (then [arm]) to hand the band + /// to the restore path for background relaunch. + static Future setOwnsBand(bool owns) async { + if (!Platform.isIOS) return; + try { + await _ch.invokeMethod('setOwnsBand', owns); + } catch (_) {} + } + /// Arm restoration for this band (its iOS peripheral UUID == PairedDevice.remoteId). static Future arm(String remoteId) async { if (!Platform.isIOS) return; diff --git a/lib/main.dart b/lib/main.dart index 9fd6941..39b41ab 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,14 +4,10 @@ import 'package:provider/provider.dart'; import 'app.dart'; import 'ble/ios_ble_restore.dart'; import 'state/app_state.dart'; -import 'sync/background_sync.dart'; import 'widget/widget_service.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - // Register the background-sync isolate entry point (no-op if the platform - // task never fires). Safe to call before runApp. - await BackgroundSync.init(); // iOS: register the CoreBluetooth-restoration wake handler. On a background // relaunch this runs too, so a band-triggered wake reaches runHeadlessSync. await IosBleRestore.init(); diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 1db9bf3..a44ecd7 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -20,7 +20,6 @@ import '../data/db.dart'; import '../data/models.dart'; import '../net/api_client.dart'; import '../live/live_activity.dart'; -import '../sync/background_sync.dart'; import '../sync/config.dart'; import '../sync/edge_tracking.dart'; import '../widget/widget_service.dart'; @@ -46,6 +45,20 @@ class AppState extends ChangeNotifier { String _prevConn = 'disconnected'; bool initialized = false; + /// True while the app is backgrounded. On iOS we KEEP the BLE connection alive in + /// this state (see [pauseForBackground]) so the OS keeps resuming us per BLE + /// notification and the live drain + flush continue. + bool _background = false; + + /// One session-long flusher. It uploads pending raw records on a steady cadence and, + /// because the uploader RETAINS rows on any non-200 (a transient rate-limit 429 + /// included), each tick also retries whatever the last tick couldn't send. No + /// per-record flushing and no backoff/cooldown — a plain ~15s cadence sits well under + /// the backend rate limit (burst 30, refill 0.5/s) on its own. On iOS the timer simply + /// fires on the next BLE-notification resume when the app was suspended. + Timer? _flushTimer; + static const Duration _kFlushInterval = Duration(seconds: 15); + bool get backendChosen => config?.chosen ?? false; bool get isAuthenticated => session?.isValid ?? false; bool get isPaired => paired != null; @@ -82,6 +95,11 @@ class AppState extends ChangeNotifier { _savedAlarm = (await SharedPreferences.getInstance()).getInt('alarm_epoch'); initialized = true; notifyListeners(); + // The flusher is connection-INDEPENDENT: it just uploads whatever's queued in + // SQLite (and retries anything a prior tick failed to send). Start it as soon as + // we're authenticated so a backlog drains even if the live connection comes up via + // _reconnect rather than openSession. + if (isAuthenticated) _startFlusher(); if (isAuthenticated && isPaired) openSession(); } @@ -94,6 +112,7 @@ class AppState extends ChangeNotifier { // Refresh failed — session already cleared by ApiClient. Drop to login. // The local upload queue persists and retries after re-login. _keepAlive = false; + _stopFlusher(); engine.disconnect(); _log('Session expired — please sign in again.'); notifyListeners(); @@ -139,6 +158,7 @@ class AppState extends ChangeNotifier { /// Verify OTP → session persisted by ApiClient. Returns true on success. Future verifyOtp(String email, String code) async { await api!.verifyOtp(email, code); + _startFlusher(); notifyListeners(); } @@ -150,8 +170,8 @@ class AppState extends ChangeNotifier { Future signOut() async { _keepAlive = false; + _stopFlusher(); IosBleRestore.foregroundActive = false; - await BackgroundSync.disable(); await EdgeTracking.stop(); await IosBleRestore.disarm(); await engine.disconnect(); @@ -159,31 +179,77 @@ class AppState extends ChangeNotifier { notifyListeners(); } - /// Called when the app goes to the background. On iOS this hands the band to the - /// CoreBluetooth restoration path: clear the foreground flag (the wake-drain is gated - /// on it) and release our live connection so the native restore central's no-timeout - /// pending connect can hold it and relaunch us when the band is reachable. Without - /// this, `foregroundActive` stays true for the whole session and the background wake - /// never drains — sync only ever happened on app open. + /// Called when the app goes to the background. + /// + /// iOS keeps an app alive in the background ONLY while it holds an active BLE + /// connection with a subscribed characteristic (UIBackgroundModes: bluetooth-central). + /// So we DELIBERATELY keep the live connection + streams up here instead of + /// disconnecting — the band keeps pushing notifications, iOS resumes us per + /// notification, and the drain+upload continue continuously. (The old code called + /// `engine.disconnect()` here, which made iOS drop the Bluetooth assertion and suspend + /// us within ~34s, so sync only ever ran when the app was reopened.) /// - /// On Android we do NOT disconnect: the Edge Tracking foreground service keeps the - /// process + connection alive, so the live drain just continues. + /// We still own the band, so the restore central must NOT arm a competing connect. + /// `BleRestoreManager` is armed only as a RECOVERY path if the connection actually + /// drops (band out of range / app jettisoned) — see [_onEngineState] / [_armRecovery]. + /// + /// On Android the Edge Tracking foreground service keeps the process + connection + /// alive, so the live drain just continues there too. Future pauseForBackground() async { + _background = true; + if (Platform.isAndroid) { + // Android: ensure the Edge Tracking foreground service is up (idempotent) so the + // process + live connection survive backgrounding; the shared flusher keeps + // uploading. No periodic task, no restore central — the service IS the keep-alive. + EdgeTracking.start(); + return; + } if (!Platform.isIOS) return; + if (engine.isConnected) { + IosBleRestore.foregroundActive = true; // "app owns the band" — don't let restore compete + await IosBleRestore.setOwnsBand(true); + _log('Backgrounded — holding live connection for continuous background sync'); + } else { + // No live connection to hold — fall back to the restore path so iOS relaunches us + // when the band reappears. + await _armRecovery(); + _log('Backgrounded — no live connection; armed iOS restore recovery'); + } + } + + /// iOS recovery: release the band to the native restore central's no-timeout pending + /// connect so the OS relaunches us when the band is reachable again. + Future _armRecovery() async { + if (!Platform.isIOS || paired == null) return; IosBleRestore.foregroundActive = false; - _keepAlive = false; // stop the drop handler from auto-reconnecting and fighting the handoff - await engine.disconnect(); - _log('Backgrounded — released band to iOS restore for background sync'); + await IosBleRestore.setOwnsBand(false); + await IosBleRestore.arm(paired!.remoteId); } Future _onRecord(Sample? sample, RawRecord raw) async { await LocalDb.insertRecord(raw, sample); } + /// Start the session-long flusher (idempotent). Cancelled on disconnect / sign-out. + void _startFlusher() { + _flushTimer ??= Timer.periodic(_kFlushInterval, (_) { + if (!uploading) unawaited(upload()); + }); + } + + void _stopFlusher() { + _flushTimer?.cancel(); + _flushTimer = null; + } + void _onEngineState(DeviceState s) { if (_prevConn != 'disconnected' && s.connection == 'disconnected') { if (_keepAlive && isPaired && !_reconnecting) { _log('Connection dropped — reconnecting…'); + // If we're backgrounded, also arm the iOS restore path: if the in-process + // reconnect can't reach the band (out of range / about to be jettisoned), the + // OS will relaunch us when it returns. + if (_background) unawaited(_armRecovery()); _reconnect(); } } @@ -210,8 +276,8 @@ class AppState extends ChangeNotifier { Future unpair() async { _keepAlive = false; + _stopFlusher(); IosBleRestore.foregroundActive = false; - await BackgroundSync.disable(); await EdgeTracking.stop(); await IosBleRestore.disarm(); await engine.disconnect(); @@ -261,11 +327,21 @@ class AppState extends ChangeNotifier { // ── session: drain history, go live, stay connected ────────────────────────── Future openSession() async { if (busy || paired == null || !isAuthenticated) return; + // Returning to the foreground with the connection still alive (kept during + // background): don't tear it down and reconnect — just reclaim ownership and flush. + final wasBackground = _background; + _background = false; + if (wasBackground && engine.isConnected) { + IosBleRestore.foregroundActive = true; + await IosBleRestore.setOwnsBand(true); + EdgeTracking.start(); // Android: keep the foreground service up (idempotent) + _startFlusher(); + unawaited(upload()); + return; + } _setBusy(true); lastError = null; _keepAlive = true; - // Keep syncing in the background. Idempotent — safe to call on every session start. - BackgroundSync.enable(); // Android: start the Edge Tracking foreground service so the live connection keeps // draining while backgrounded (Android kills background processes otherwise). EdgeTracking.start(); @@ -286,13 +362,11 @@ class AppState extends ChangeNotifier { await engine.getAlarm(); _log('Live session active.'); - final flush = Timer.periodic(const Duration(seconds: 15), (_) => upload()); - late final SyncReport report; - try { - report = await engine.runSync(); - } finally { - flush.cancel(); - } + // Start the session-long flusher (it keeps running after the drain, flushing live + // records + retrying anything a tick failed to send). Replaces the old + // drain-scoped timer that stopped once history finished. + _startFlusher(); + final report = await engine.runSync(); _log('Drained ${report.records} records in ${report.batches} batches ' '(${report.complete ? "complete" : "idle-stopped"}).'); dbCounts = await LocalDb.counts(); @@ -312,6 +386,13 @@ class AppState extends ChangeNotifier { await Future.delayed(Duration(seconds: 2 * attempt)); if (!_keepAlive) break; if (await engine.connectToRemoteId(paired!.remoteId)) { + // Reclaim the band from the iOS restore central so it stops competing. + if (Platform.isIOS) { + IosBleRestore.foregroundActive = true; + await IosBleRestore.setOwnsBand(true); + } + _startFlusher(); // ensure live uploads run even on a reconnect-only path + EdgeTracking.start(); // ensure the Android foreground service is up too await engine.runSync(timeout: const Duration(seconds: 30)); await upload(); await engine.enableLiveStreams(); @@ -330,6 +411,7 @@ class AppState extends ChangeNotifier { Future endSession() async { _keepAlive = false; + _stopFlusher(); await engine.disconnect(); } @@ -364,7 +446,10 @@ class AppState extends ChangeNotifier { notifyListeners(); }); if (result.ok) { - _log('Uploaded ${result.accepted}/${result.attempted} records.'); + // Suppress the every-tick "0/0" noise — the flusher polls on a steady cadence. + if (result.attempted > 0) { + _log('Uploaded ${result.accepted}/${result.attempted} records.'); + } } else { lastError = 'Upload failed: ${result.error}'; _log(lastError!); diff --git a/lib/sync/background_sync.dart b/lib/sync/background_sync.dart index 9686fff..8f5eaa1 100644 --- a/lib/sync/background_sync.dart +++ b/lib/sync/background_sync.dart @@ -1,15 +1,17 @@ -// Background sync — runs the connect → drain → upload flow with NO UI, NO -// Provider, NO foreground service / sticky notification. "Comes, does its job, -// goes." Reused by the OS periodic scheduler (WorkManager on Android, BGTask on -// iOS via the workmanager plugin) and callable directly. +// Headless sync — runs the connect → drain → upload flow with NO UI, NO Provider. +// "Comes, does its job, goes." Invoked by the iOS CoreBluetooth-restoration RECOVERY +// path (ios_ble_restore.dart) when the band reappears after the live connection dropped. +// +// There is NO OS periodic scheduler anymore (no WorkManager 15-min task, no BGTask): +// continuous sync is the kept-alive live connection + the persistent flusher in +// AppState. This is purely the relaunch-recovery fallback. // // Connectivity-agnostic by design: it does NOT assume the strap stays connected. -// Each run just connects-by-id if reachable, drains whatever the band buffered to -// flash (non-destructive cursor — catches up everything since last time), uploads, -// and disconnects. A missed window is harmless; the next run catches up. +// It connects-by-id if reachable, drains whatever the band buffered to flash +// (non-destructive cursor — catches up everything since last time), uploads, and +// disconnects. A missed run is harmless; the next reconnect catches up. import 'package:flutter/widgets.dart'; -import 'package:workmanager/workmanager.dart'; import '../ble/ble_engine.dart'; import '../data/db.dart'; @@ -17,11 +19,7 @@ import '../net/api_client.dart'; import 'config.dart'; import 'uploader.dart'; -/// Unique name + tag for the periodic OS task. -const String _kPeriodicTask = 'openstrap.periodicSync'; - -/// One headless sync pass. Safe to call from a background isolate. Never throws — -/// returns true so the OS scheduler treats the run as handled (no thrash-retry). +/// One headless sync pass. Safe to call from a background isolate. Never throws. Future runHeadlessSync() async { WidgetsFlutterBinding.ensureInitialized(); try { @@ -69,38 +67,3 @@ Future runHeadlessSync() async { return true; } } - -/// WorkManager/BGTask entry point. MUST be a top-level function annotated for the -/// AOT compiler so the background isolate can find it. -@pragma('vm:entry-point') -void callbackDispatcher() { - Workmanager().executeTask((task, inputData) => runHeadlessSync()); -} - -/// Thin facade over the OS scheduler. -class BackgroundSync { - /// Call once at app start (registers the isolate entry point). - static Future init() async { - await Workmanager().initialize(callbackDispatcher); - } - - /// Schedule the periodic background sync. 15 min is the OS floor; the OS may run - /// it less often (Doze / iOS throttling) — fine, since the drain catches up. - /// Requires network; idempotent (keep existing if already scheduled). - static Future enable() async { - await Workmanager().registerPeriodicTask( - _kPeriodicTask, - _kPeriodicTask, - frequency: const Duration(minutes: 15), - constraints: Constraints(networkType: NetworkType.connected), - existingWorkPolicy: ExistingPeriodicWorkPolicy.keep, - backoffPolicy: BackoffPolicy.linear, - backoffPolicyDelay: const Duration(minutes: 5), - ); - } - - /// Stop background sync (on sign-out / unpair). - static Future disable() async { - await Workmanager().cancelByUniqueName(_kPeriodicTask); - } -} diff --git a/lib/sync/uploader.dart b/lib/sync/uploader.dart index b7f7950..f111629 100644 --- a/lib/sync/uploader.dart +++ b/lib/sync/uploader.dart @@ -30,7 +30,12 @@ class Uploader { attempted += batch.length; try { final body = await api.ingestBatch(batch.map((r) => r.hex).toList()); - accepted += (body['processed'] as int?) ?? 0; + // The backend persists every record raw (R2 = system of record) and returns + // a count. Older builds returned no count field; fall back to the batch size + // since a 2xx means the server received + stored them all. + accepted += (body['received'] as int?) ?? + (body['processed'] as int?) ?? + batch.length; await LocalDb.markUploaded(batch.map((r) => r.hex).toList()); if (onChunk != null) await onChunk(); } catch (e) { diff --git a/pubspec.lock b/pubspec.lock index 802c0c4..1eb6619 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -981,38 +981,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" - workmanager: - dependency: "direct main" - description: - name: workmanager - sha256: "065673b2a465865183093806925419d311a9a5e0995aa74ccf8920fd695e2d10" - url: "https://pub.dev" - source: hosted - version: "0.9.0+3" - workmanager_android: - dependency: transitive - description: - name: workmanager_android - sha256: "9ae744db4ef891f5fcd2fb8671fccc712f4f96489a487a1411e0c8675e5e8cb7" - url: "https://pub.dev" - source: hosted - version: "0.9.0+2" - workmanager_apple: - dependency: transitive - description: - name: workmanager_apple - sha256: "1cc12ae3cbf5535e72f7ba4fde0c12dd11b757caf493a28e22d684052701f2ca" - url: "https://pub.dev" - source: hosted - version: "0.9.1+2" - workmanager_platform_interface: - dependency: transitive - description: - name: workmanager_platform_interface - sha256: f40422f10b970c67abb84230b44da22b075147637532ac501729256fcea10a47 - url: "https://pub.dev" - source: hosted - version: "0.9.1+1" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e780175..bbebbfb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,10 +36,6 @@ dependencies: # Share recap cards (rendered card → image → share sheet). share_plus: ^10.1.4 - # Background sync — OS-scheduled periodic task (Android WorkManager / iOS BGTask). - # No foreground service, no persistent notification. - workmanager: ^0.9.0 - # Home/lock-screen widget bridge (writes a snapshot to the App Group → WidgetKit). home_widget: ^0.6.0 From 0da7bc0dd64c0693bfb297c13cde6ce3c377c39a Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 14 Jun 2026 04:02:58 +0530 Subject: [PATCH 04/55] =?UTF-8?q?edge:=20concern-based=20IA=20=E2=80=94=20?= =?UTF-8?q?Today/Sleep/Heart/Body/Workouts,=20studio=20polish,=20full=20me?= =?UTF-8?q?tric=20surfacing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the app around per-concern screens reached from a 5-tab shell (Today · Sleep · Heart · Body · Workouts, shoe icon) + Today gauges. - ConcernScreen: one reusable screen per concern with Today/Week/Month/3M, a GlowCard hero (display number + delta + tappable bars inside, readable labels), inline drill (month→week→day) keyed by date, SectionHeader rhythm — matches the hand-written look. - Sleep tab = the rich SleepDetailScreen embedded; Body tab = StrainDetailScreen embedded (added `embedded` mode to both); Heart = composed card (recovery/RHR GlowCard hero → HR timeline → full HRV RMSSD/SDNN/LF-HF → stress → zones w/ legend → nocturnal → respiratory → illness 3-signal breakdown). - MetricRow + kMetricInfo: every metric shows a one-line "what this is" on its own full-width line; proper padding (fixes the cramped vertical-only cards). - "What affected this" is display-only (no navigation loop); per-concern Records + journal Patterns resurfaced on Today tabs. - Today de-duped: gauges open the concern screens; removed duplicate Sleep/Day-strain tiles, Stats/Activity tabs, Lungs tab (resp/SpO2 → Sleep+Heart). Profile via gear icon (wrapped in a Scaffold when pushed). Back buttons on pushed concern screens. Numbers on bar charts. Removed the orphaned MetricExplorer. - api_client: /trend, /day/heart, /day/lungs, workouts endpoints. flutter analyze clean (pre-existing info lints only). --- ios/Podfile.lock | 6 - lib/app.dart | 20 +- lib/models/payloads.dart | 4 +- lib/net/api_client.dart | 46 +- lib/ui/activity/strain_detail_screen.dart | 92 +++- lib/ui/concern/concern_screen.dart | 282 ++++++++++++ lib/ui/concern/concern_screens.dart | 68 +++ lib/ui/concern/detail_cards.dart | 517 ++++++++++++++++++++++ lib/ui/concern/metric_row.dart | 137 ++++++ lib/ui/kit/charts.dart | 77 +++- lib/ui/sleep/sleep_detail_screen.dart | 86 ++-- lib/ui/today/today_screen.dart | 149 ++----- lib/widget/widget_service.dart | 2 +- 13 files changed, 1267 insertions(+), 219 deletions(-) create mode 100644 lib/ui/concern/concern_screen.dart create mode 100644 lib/ui/concern/concern_screens.dart create mode 100644 lib/ui/concern/detail_cards.dart create mode 100644 lib/ui/concern/metric_row.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock index dc46627..ae8bfe2 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -13,8 +13,6 @@ PODS: - sqflite_darwin (0.0.4): - Flutter - FlutterMacOS - - workmanager_apple (0.0.1): - - Flutter DEPENDENCIES: - Flutter (from `Flutter`) @@ -23,7 +21,6 @@ DEPENDENCIES: - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - - workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`) EXTERNAL SOURCES: Flutter: @@ -38,8 +35,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" sqflite_darwin: :path: ".symlinks/plugins/sqflite_darwin/darwin" - workmanager_apple: - :path: ".symlinks/plugins/workmanager_apple/ios" SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 @@ -48,7 +43,6 @@ SPEC CHECKSUMS: share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 - workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778 PODFILE CHECKSUM: d356a3c7ef9fe1586fd8829c4e39f9dee09401b4 diff --git a/lib/app.dart b/lib/app.dart index 4500cb7..df53e9c 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -6,13 +6,11 @@ import 'state/app_state.dart'; import 'theme/theme.dart'; import 'theme/tokens.dart'; import 'ui/kit/kit.dart'; -import 'ui/activity/activity_screen.dart'; import 'ui/onboarding_screens.dart'; import 'ui/pairing_screen.dart'; -import 'ui/profile/profile_screen.dart'; -import 'ui/sleep/sleep_screen.dart'; import 'ui/today/today_screen.dart'; -import 'ui/trends/trends_screen.dart'; +import 'ui/concern/concern_screens.dart'; +import 'ui/workouts/workouts_screen.dart'; class OpenStrapApp extends StatefulWidget { const OpenStrapApp({super.key}); @@ -88,18 +86,18 @@ class _ShellState extends State<_Shell> { static const _pages = [ TodayScreen(), - SleepScreen(), - ActivityScreen(), - TrendsScreen(), - ProfileScreen(), + SleepConcernScreen(), + HeartConcernScreen(), + BodyConcernScreen(), + WorkoutsScreen(), ]; static const _nav = [ (Ic.home, 'Today'), (Ic.sleep, 'Sleep'), - (Ic.activity, 'Activity'), - (Ic.stats, 'Stats'), - (Ic.profile, 'You'), + (Ic.heart, 'Heart'), + (Ic.strain, 'Body'), + (Ic.run, 'Workouts'), ]; @override diff --git a/lib/models/payloads.dart b/lib/models/payloads.dart index ebf4b19..e1c503c 100644 --- a/lib/models/payloads.dart +++ b/lib/models/payloads.dart @@ -87,7 +87,9 @@ class TodayData { /// Respiratory rate (PPG) — only present once validated server-side; else null. RespData? get resp => _resp == null ? null : RespData(_resp); - Metric get readiness => metricOf(_daily, 'readiness'); + // Recovery is HRV-based now (replaces the old heuristic readiness). Kept the + // getter name `readiness` is dropped in favour of `recovery`. + Metric get recovery => metricOf(_daily, 'recovery'); Metric get strain => metricOf(_daily, 'strain'); Metric get restingHr => metricOf(_daily, 'resting_hr'); Metric get rhrDelta => metricOf(_daily, 'resting_hr_delta'); diff --git a/lib/net/api_client.dart b/lib/net/api_client.dart index 0980c39..f6c459e 100644 --- a/lib/net/api_client.dart +++ b/lib/net/api_client.dart @@ -222,10 +222,54 @@ class ApiClient { Future> getDayTimeline(String date) => _getObj('/day/timeline', {'date': date}); - /// GET /day/stress?date= → per-minute arousal band + buckets + peak (NOT HRV). + /// GET /day/stress?date= → HRV stress + sleep-arousal + factual HR timeline. Future> getDayStress(String date) => _getObj('/day/stress', {'date': date}); + /// GET /day/heart?date= → 24h HR + RHR + HRV + zones + nocturnal + recovery + + /// stress + illness + drivers (everything heart/autonomic for a day). + Future> getDayHeart(String date) => + _getObj('/day/heart', {'date': date}); + + /// GET /day/lungs?date= → respiratory rate (RSA, gated) + relative SpO₂. + Future> getDayLungs(String date) => + _getObj('/day/lungs', {'date': date}); + + // ── workouts (manual/live/auto) ────────────────────────────────────────── + /// GET /workouts?range=week|month|quarter → list + training-volume summary. + Future> getWorkouts({String range = 'month'}) => + _getObj('/workouts', {'range': range}); + + /// GET /workout/:id → one workout's breakdown + HR timeline. + Future> getWorkout(String id) => _getObj('/workout/$id'); + + /// POST /workout/start {type} → {workout_id, start_ts, type, status}. + Future> startWorkout(String type, {String? title}) async { + final resp = await _authed((h) => _client.post(_u('/workout/start'), + headers: h, body: jsonEncode({'type': type, if (title != null) 'title': title}))); + if (resp.statusCode != 200) throw ApiException(resp.statusCode, resp.body); + return _decode(resp.body); + } + + /// POST /workout/end {workout_id} → computed breakdown. + Future> endWorkout(String workoutId) async { + final resp = await _authed((h) => _client.post(_u('/workout/end'), + headers: h, body: jsonEncode({'workout_id': workoutId}))); + if (resp.statusCode != 200) throw ApiException(resp.statusCode, resp.body); + return _decode(resp.body); + } + + /// GET /trend/:metric?scale=week|month|quarter&anchor=YYYY-MM-DD + /// → server-aggregated buckets (7 daily / weekly-mean / monthly-mean bars) with + /// coverage + target + achieved, for the Metric Explorer. Drill = re-call with a + /// narrower scale+anchor; the leaf is /day/*. + Future> getTrend(String metric, + {String scale = 'week', String? anchor}) => + _getObj('/trend/$metric', { + 'scale': scale, + if (anchor != null) 'anchor': anchor, + }); + /// GET /records → personal records + streaks + baseline drift (your body over time). Future> getRecords() => _getObj('/records'); diff --git a/lib/ui/activity/strain_detail_screen.dart b/lib/ui/activity/strain_detail_screen.dart index fd1843c..e3be0d3 100644 --- a/lib/ui/activity/strain_detail_screen.dart +++ b/lib/ui/activity/strain_detail_screen.dart @@ -13,7 +13,9 @@ import '../kit/charts.dart'; class StrainDetailScreen extends StatefulWidget { final String date; // 'YYYY-MM-DD' - const StrainDetailScreen({super.key, required this.date}); + // Embedded (no Scaffold/back bar) for use inside the Body ConcernScreen. + final bool embedded; + const StrainDetailScreen({super.key, required this.date, this.embedded = false}); @override State createState() => _StrainDetailScreenState(); } @@ -131,8 +133,24 @@ class _StrainDetailScreenState extends State { // ── build ──────────────────────────────────────────────────────────────────── + List _sections() { + if (_phase == _Phase.loading) return [_loading()]; + if (_phase == _Phase.empty) { + return [_stateCard(Ic.strain, 'No strain for this day', + 'Wear your strap and sync to capture all-day heart rate. Strain ' + 'appears once there is data to score.')]; + } + if (_phase == _Phase.error) { + return [_stateCard(Ic.cloud, "Couldn't load strain", _error ?? 'Please try again.')]; + } + return _content(); + } + @override Widget build(BuildContext context) { + if (widget.embedded) { + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: _sections()); + } return Scaffold( backgroundColor: AppColors.bg, body: SafeArea( @@ -143,23 +161,7 @@ class _StrainDetailScreenState extends State { const SizedBox(height: Sp.x4), _topBar(), const SizedBox(height: Sp.x6), - if (_phase == _Phase.loading) - _loading() - else if (_phase == _Phase.empty) - _stateCard( - Ic.strain, - 'No strain for this day', - 'Wear your strap and sync to capture all-day heart rate. Strain ' - 'appears once there is data to score.', - ) - else if (_phase == _Phase.error) - _stateCard( - Ic.cloud, - "Couldn't load strain", - _error ?? 'Please try again.', - ) - else - ..._content(), + ..._sections(), const SizedBox(height: 40), ], ), @@ -189,9 +191,21 @@ class _StrainDetailScreenState extends State { } List _content() { + final load = _map(_data['load']); + final fitness = _data['fitness_trend']?.toString(); + final cals = _num(_data['calories']); + final steps = _num(_data['steps']); + final hasLoad = load.isNotEmpty || fitness != null || cals != null || steps != null; + final drivers = [for (final dr in _list(_map(_data['drivers'])['strain'])) _map(dr)] + .where((dr) => (dr['label']?.toString() ?? '').isNotEmpty).toList(); return [ _hero(), const SizedBox(height: Sp.x4), + if (hasLoad) ...[ + const SectionHeader('Training load'), + _loadCard(load, fitness, cals, steps), + const SizedBox(height: Sp.x4), + ], _curveCard(), const SizedBox(height: Sp.x4), _zonesCard(), @@ -199,9 +213,51 @@ class _StrainDetailScreenState extends State { _hrStatsRow(), const SizedBox(height: Sp.x4), ..._workouts(), + if (drivers.isNotEmpty) ...[ + const SizedBox(height: Sp.x4), + const SectionHeader('What affected this'), + // Display-only (no navigation): default card padding gives proper inset. + ProCard(child: Column(children: [ + for (final dr in drivers) + DetailRow(label: dr['label']?.toString() ?? '', value: dr['detail']?.toString() ?? ''), + ])), + ], ]; } + Widget _loadCard(Map load, String? fitness, num? cals, num? steps) { + final acwr = _num(load['acwr']); + final band = load['band']?.toString(); + Color bandColor() { + switch (band) { + case 'optimal': return AppColors.good; + case 'caution': return AppColors.warn; + case 'high-risk': return AppColors.bad; + default: return AppColors.loadDetraining; + } + } + return ProCard( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (acwr != null) ...[ + Row(children: [ + const AppIcon(Ic.strain, size: 18, color: AppColors.coralDeep), + const SizedBox(width: Sp.x2), + Text('Acute:chronic load', style: AppText.label), + const Spacer(), + Text(acwr.toStringAsFixed(2), style: AppText.metricSm.copyWith(fontSize: 18)), + const SizedBox(width: Sp.x2), + if (band != null) Tag(band, color: bandColor()), + ]), + if (fitness != null || cals != null || steps != null) + const SizedBox(height: Sp.x3), + ], + if (fitness != null) DetailRow(label: 'Fitness trend', value: fitness), + if (cals != null) DetailRow(label: 'Active calories', value: '${cals.round()} kcal'), + if (steps != null) DetailRow(label: 'Steps', value: '${steps.round()}'), + ]), + ); + } + // ── 2. HERO ─────────────────────────────────────────────────────────────────── Widget _hero() { diff --git a/lib/ui/concern/concern_screen.dart b/lib/ui/concern/concern_screen.dart new file mode 100644 index 0000000..2495a58 --- /dev/null +++ b/lib/ui/concern/concern_screen.dart @@ -0,0 +1,282 @@ +// ConcernScreen — the ONE reusable screen every concern (Sleep/Heart/Body/…) plugs +// into. Title + right-aligned scale toggle (Today·Week·Month·3M), exactly like the +// hand-written Stats screen. The over-time view is a GlowCard HERO (overline → big +// display number + delta → subtitle → tappable bars inside it), then inline drill: +// tap a month bar → its weeks expand below → tap a week → its 7 days → tap a day → +// the concern's rich detail. All from the existing kit; numbers on every bar. + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../state/app_state.dart'; +import '../../theme/theme.dart'; +import '../../theme/tokens.dart'; +import '../kit/kit.dart'; +import '../kit/charts.dart'; + +typedef DetailBuilder = Widget Function(BuildContext context); +typedef DayDetailBuilder = Widget Function(BuildContext context, String date); + +const _tabs = ['Today', 'Week', 'Month', '3M']; +const _wd = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; +const _mon = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +class ConcernScreen extends StatefulWidget { + final String title; + final String metric; // /trend key for the bars + final IconData icon; + final Color accent; + final String Function(double v)? valueFmt; + final DetailBuilder todayDetail; + final DayDetailBuilder dayDetail; + const ConcernScreen({ + super.key, + required this.title, + required this.metric, + required this.icon, + required this.accent, + required this.todayDetail, + required this.dayDetail, + this.valueFmt, + }); + + @override + State createState() => _ConcernScreenState(); +} + +class _ConcernScreenState extends State { + int _tab = 0; + + @override + Widget build(BuildContext context) { + final scale = _tab == 1 ? 'week' : _tab == 2 ? 'month' : 'quarter'; + return Scaffold( + // Opaque bg so a PUSHED concern screen (from a gauge / driver) isn't a black + // backdrop; as a tab it matches the shell background anyway. + backgroundColor: AppColors.bg, + body: SafeArea( + bottom: false, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: Sp.screen), + children: [ + const SizedBox(height: Sp.x4), + Row(children: [ + // Back button only when this screen was pushed (not when it's a tab). + if (Navigator.of(context).canPop()) ...[ + RoundIconButton(Ic.arrowLeft, onTap: () => Navigator.of(context).maybePop()), + const SizedBox(width: Sp.x3), + ], + Expanded(child: Text(widget.title, style: AppText.h1)), + SegToggle(options: _tabs, index: _tab, onChanged: (i) => setState(() => _tab = i)), + ]), + const SizedBox(height: Sp.x5), + if (_tab == 0) + widget.todayDetail(context) + else + _DrillLevel( + key: ValueKey('$scale-root'), + title: widget.title, + icon: widget.icon, + metric: widget.metric, + scale: scale, + anchor: null, + accent: widget.accent, + valueFmt: widget.valueFmt, + dayDetail: widget.dayDetail, + ), + const SizedBox(height: 110), + ], + ), + ), + ); + } +} + +/// One level of bars (a /trend call) rendered as a GlowCard hero. Tapping a bar +/// expands a finer level (quarter→month→week) or, at week, the day detail — inline. +class _DrillLevel extends StatefulWidget { + final String title; + final IconData icon; + final String metric; + final String scale; // 'week' | 'month' | 'quarter' + final String? anchor; + final Color accent; + final String Function(double v)? valueFmt; + final DayDetailBuilder dayDetail; + const _DrillLevel({ + super.key, + required this.title, + required this.icon, + required this.metric, + required this.scale, + required this.anchor, + required this.accent, + required this.dayDetail, + this.valueFmt, + }); + + @override + State<_DrillLevel> createState() => _DrillLevelState(); +} + +class _DrillLevelState extends State<_DrillLevel> { + Map? _data; + bool _loading = true; + int? _selected; + Widget? _child; + String? _childLabel; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final api = context.read().api; + if (api == null) return; + try { + final d = await api.getTrend(widget.metric, scale: widget.scale, anchor: widget.anchor); + if (!mounted) return; + setState(() { _data = d; _loading = false; }); + } catch (_) { + if (!mounted) return; + setState(() => _loading = false); + } + } + + // Nicer bar labels than the raw backend strings. + String _barLabel(int i, Map b) { + final ts = (b['t_start'] as num?)?.toInt(); + if (ts == null) return b['label']?.toString() ?? ''; + final d = DateTime.fromMillisecondsSinceEpoch(ts * 1000, isUtc: true); + switch (widget.scale) { + case 'week': + return _wd[(d.weekday - 1) % 7]; + case 'month': + return 'W${i + 1}'; + default: // quarter → month + return _mon[d.month - 1]; + } + } + + void _tap(int i, List buckets) { + if (i >= buckets.length) return; + final b = buckets[i] as Map; + final endTs = (b['t_end'] as num?)?.toInt(); + if (endTs == null) return; + final lastDay = DateTime.fromMillisecondsSinceEpoch((endTs - 86400) * 1000, isUtc: true) + .toIso8601String().substring(0, 10); + setState(() { + if (_selected == i) { _selected = null; _child = null; return; } + _selected = i; + _childLabel = _barLabel(i, b); + if (widget.scale == 'week') { + final d = DateTime.fromMillisecondsSinceEpoch((endTs - 86400) * 1000, isUtc: true); + _childLabel = '${_wd[(d.weekday - 1) % 7]}, ${_mon[d.month - 1]} ${d.day}'; + _child = KeyedSubtree(key: ValueKey('day-$lastDay'), child: widget.dayDetail(context, lastDay)); + } else { + _child = _DrillLevel( + key: ValueKey('${widget.scale}-$lastDay'), + title: widget.title, icon: widget.icon, metric: widget.metric, + scale: widget.scale == 'quarter' ? 'month' : 'week', + anchor: lastDay, accent: widget.accent, valueFmt: widget.valueFmt, dayDetail: widget.dayDetail, + ); + } + }); + } + + String get _period => widget.scale == 'week' + ? 'this week' : widget.scale == 'month' ? 'this month' : 'last 3 months'; + + @override + Widget build(BuildContext context) { + if (_loading) { + return const ProCard( + padding: EdgeInsets.all(Sp.x6), + child: SizedBox(height: 200, child: Center( + child: SizedBox(width: 22, height: 22, + child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.coral)))), + ); + } + final buckets = (_data?['buckets'] as List?) ?? const []; + final unit = _data?['unit']?.toString() ?? ''; + final label = _data?['label']?.toString() ?? widget.title; + final summary = (_data?['summary'] as Map?)?.cast(); + final values = [for (final b in buckets) ((b as Map)['value'] as num?)?.toDouble() ?? 0.0]; + final labels = [for (int i = 0; i < buckets.length; i++) _barLabel(i, buckets[i] as Map)]; + final allZero = values.every((v) => v == 0); + final avg = summary?['avg']; + final delta = summary?['delta_vs_prev']; + final met = summary?['met_count'], total = summary?['total']; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GlowCard( + padding: const EdgeInsets.all(Sp.x6), + glow: widget.accent, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + AppIcon(widget.icon, size: 18, color: widget.accent), + const SizedBox(width: Sp.x2), + Text('AVG ${label.toUpperCase()}', style: AppText.overline), + const Spacer(), + if (met != null && total != null && (total as num) > 0) + Text('$met/$total met', style: AppText.caption), + ]), + const SizedBox(height: Sp.x4), + Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ + Text(_fmtAvg(avg, unit), style: AppText.display), + if (unit.isNotEmpty && avg != null && widget.metric != 'sleep') ...[ + const SizedBox(width: Sp.x2), + Padding(padding: const EdgeInsets.only(bottom: 8), + child: Text(unit, style: AppText.bodySoft)), + ], + if (delta != null && (delta as num) != 0) ...[ + const SizedBox(width: Sp.x3), + Padding(padding: const EdgeInsets.only(bottom: 8), child: DeltaChip(delta)), + ], + ]), + const SizedBox(height: Sp.x2), + Text('avg · $_period', style: AppText.bodySoft), + const SizedBox(height: Sp.x5), + if (allZero) + SizedBox(height: 120, child: Center( + child: Text('No data in this period', style: AppText.captionMuted))) + else + LabeledBars( + values: values, labels: labels, color: widget.accent, + highlight: _selected, valueFmt: widget.valueFmt, onTapBar: (i) => _tap(i, buckets), + ), + ], + ), + ), + if (!allZero) ...[ + const SizedBox(height: Sp.x2), + Center(child: Text( + widget.scale == 'week' ? 'Tap a day for the full breakdown' : 'Tap a bar to drill in', + style: AppText.captionMuted)), + ], + if (_child != null) ...[ + const SizedBox(height: Sp.x6), + SectionHeader(_childLabel ?? 'Detail'), + _child!, + ], + ], + ); + } + + String _fmtAvg(Object? avg, String unit) { + if (avg == null) return '—'; + final v = (avg as num).toDouble(); + // Sleep avg comes in minutes → show as Hh Mm in the hero. + if (widget.metric == 'sleep') { + final m = v.round(); + return '${m ~/ 60}h ${m % 60}m'; + } + return v == v.roundToDouble() ? v.toStringAsFixed(0) : v.toStringAsFixed(1); + } +} diff --git a/lib/ui/concern/concern_screens.dart b/lib/ui/concern/concern_screens.dart new file mode 100644 index 0000000..a59d81e --- /dev/null +++ b/lib/ui/concern/concern_screens.dart @@ -0,0 +1,68 @@ +// The concern screens — thin wrappers that configure the reusable ConcernScreen +// with each concern's trend metric + detail card. Reached from the navbar and from +// Today's per-concern cards (one canonical screen, reached from everywhere). + +import 'package:flutter/material.dart'; +import '../../theme/tokens.dart'; +import '../kit/kit.dart'; +import '../sleep/sleep_detail_screen.dart'; +import '../activity/strain_detail_screen.dart'; +import 'concern_screen.dart'; +import 'detail_cards.dart'; + +String todayUtc() => DateTime.now().toUtc().toIso8601String().substring(0, 10); + + +class SleepConcernScreen extends StatelessWidget { + const SleepConcernScreen({super.key}); + @override + Widget build(BuildContext context) => ConcernScreen( + title: 'Sleep', + metric: 'sleep', + icon: Ic.moon, + accent: AppColors.loadDetraining, + valueFmt: (v) => v == 0 ? '' : (v / 60).toStringAsFixed(1), // minutes → hours on bars + // The exact rich Sleep screen you love, embedded under the time toggle, + // plus sleep records + journal patterns on Today. + todayDetail: (ctx) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + SleepDetailScreen(date: todayUtc(), embedded: true), + const ConcernExtras(concern: 'sleep'), + ]), + dayDetail: (ctx, date) => SleepDetailScreen(date: date, embedded: true), + ); +} + +class HeartConcernScreen extends StatelessWidget { + const HeartConcernScreen({super.key}); + @override + Widget build(BuildContext context) => ConcernScreen( + title: 'Heart', + metric: 'resting_hr', // stable daily series for the bars + icon: Ic.heart, + accent: AppColors.coral, + todayDetail: (ctx) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + HeartDayCard(date: todayUtc()), + const ConcernExtras(concern: 'heart'), + ]), + dayDetail: (ctx, date) => HeartDayCard(date: date), + ); +} + +/// Body — strain / training load / calories / steps / activity. Bars track daily +/// strain; the detail is the rich Strain screen (embedded), reused over time. +/// (Respiratory rate + SpO₂ moved to Sleep + Heart; Lungs no longer a tab.) +class BodyConcernScreen extends StatelessWidget { + const BodyConcernScreen({super.key}); + @override + Widget build(BuildContext context) => ConcernScreen( + title: 'Body', + metric: 'strain', + icon: Ic.strain, + accent: AppColors.coral, + todayDetail: (ctx) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + StrainDetailScreen(date: todayUtc(), embedded: true), + const ConcernExtras(concern: 'body'), + ]), + dayDetail: (ctx, date) => StrainDetailScreen(date: date, embedded: true), + ); +} diff --git a/lib/ui/concern/detail_cards.dart b/lib/ui/concern/detail_cards.dart new file mode 100644 index 0000000..f873238 --- /dev/null +++ b/lib/ui/concern/detail_cards.dart @@ -0,0 +1,517 @@ +// Per-concern day-detail cards. Each fetches its /day/* endpoint and renders with +// the EXISTING kit (RingStat, SegmentBar, DetailRow, ProCard, StatTile) — no new +// widget types. Used both for the "Today" tab (date = today) and as the inline +// drill leaf (date = the tapped day). One card per concern keeps it DRY. + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../state/app_state.dart'; +import '../../theme/theme.dart'; +import '../../theme/tokens.dart'; +import '../kit/kit.dart'; +import '../kit/charts.dart'; +import 'metric_row.dart'; + +String hm(num? minutes) { + if (minutes == null) return '—'; + final m = minutes.round(); + return '${m ~/ 60}h ${m % 60}m'; +} + +/// Shared async wrapper: fetch a map, render via builder; spinner/empty states. +class _Fetch extends StatefulWidget { + final Future> Function(dynamic api) load; + final Widget Function(Map data) build; + const _Fetch({required this.load, required this.build}); + @override + State<_Fetch> createState() => _FetchState(); +} + +class _FetchState extends State<_Fetch> { + Map? _d; + bool _loading = true; + @override + void initState() { + super.initState(); + _go(); + } + Future _go() async { + final api = context.read().api; + if (api == null) return; + try { + final d = await widget.load(api); + if (mounted) setState(() { _d = d; _loading = false; }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + @override + Widget build(BuildContext context) { + if (_loading) { + return const Padding(padding: EdgeInsets.all(Sp.x5), child: Center(child: CircularProgressIndicator())); + } + if (_d == null) { + return ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x4), child: Text('No data', style: AppText.captionMuted))); + } + return widget.build(_d!); + } +} + +// Zone palette reused across concerns. +const _zoneColors = [AppColors.cool, AppColors.loadDetraining, AppColors.good, AppColors.warn, AppColors.coral]; + +// ── SLEEP ──────────────────────────────────────────────────────────────────── +class SleepDayCard extends StatelessWidget { + final String date; + const SleepDayCard({super.key, required this.date}); + @override + Widget build(BuildContext context) { + return _Fetch( + load: (api) => api.getDaySleep(date), + build: (d) { + if (d['has_sleep'] != true) { + return ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x5), + child: Center(child: Text('No sleep recorded for this night', style: AppText.captionMuted)))); + } + final dur = (d['duration_min'] as num?)?.toDouble() ?? 0; + final need = (d['need_min'] as num?)?.toDouble() ?? 480; + final eff = (d['efficiency'] as num?)?.toDouble(); + final st = (d['stages'] as Map?) ?? const {}; + final light = (st['light_min'] as num?)?.toDouble() ?? 0; + final deep = (st['deep_min'] as num?)?.toDouble() ?? 0; + final rem = (st['rem_min'] as num?)?.toDouble() ?? 0; + final reg = d['regularity']; + final debt = d['debt_min']; + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Hero: ring + "Xh Ym of N.Nh need" — the layout you liked. + ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x4), child: Row(children: [ + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('TIME ASLEEP', style: AppText.overline), + const SizedBox(height: Sp.x2), + Text(hm(dur), style: AppText.metric), + const SizedBox(height: 2), + Text('of ${(need / 60).toStringAsFixed(1)}h need', style: AppText.captionMuted), + ])), + RingStat( + t: need > 0 ? (dur / need).clamp(0.0, 1.0) : 0, + color: AppColors.loadDetraining, size: 96, stroke: 10, + center: Text('${need > 0 ? ((dur / need) * 100).round() : 0}%', + style: AppText.metricSm.copyWith(color: AppColors.loadDetraining)), + ), + ]))), + const SizedBox(height: Sp.x3), + // Stages. + ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x4), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [Text('Stages', style: AppText.label), const Spacer(), const Tag('BETA', color: AppColors.warn)]), + const SizedBox(height: Sp.x3), + SegmentBar([deep, rem, light], const [AppColors.coral, AppColors.coralSoft, AppColors.cool], height: 14), + const SizedBox(height: Sp.x3), + _stageRow('Deep', deep, AppColors.coral), + _stageRow('REM', rem, AppColors.coralSoft), + _stageRow('Light', light, AppColors.cool), + ]))), + const SizedBox(height: Sp.x3), + ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x2), child: Column(children: [ + if (eff != null) DetailRow(label: 'Efficiency', value: '${(eff * 100).round()}%'), + if (reg != null) DetailRow(label: 'Regularity (SRI)', value: '${(reg as num).round()}/100'), + if (debt != null) DetailRow(label: 'Sleep debt', value: hm(debt as num)), + ]))), + ]); + }, + ); + } + + Widget _stageRow(String label, double min, Color c) => Padding( + padding: const EdgeInsets.only(bottom: Sp.x2), + child: Row(children: [ + Container(width: 10, height: 10, decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(3))), + const SizedBox(width: Sp.x2), + Text(label, style: AppText.body), + const Spacer(), + Text(hm(min), style: AppText.label), + ]), + ); +} + +// ── HEART ──────────────────────────────────────────────────────────────────── +class HeartDayCard extends StatelessWidget { + final String date; + const HeartDayCard({super.key, required this.date}); + + num? _n(Object? v) => v is num ? v : null; + + List _zoneVals(Map z) => [ + (z['zone1_min'] as num?)?.toDouble() ?? 0, (z['zone2_min'] as num?)?.toDouble() ?? 0, + (z['zone3_min'] as num?)?.toDouble() ?? 0, (z['zone4_min'] as num?)?.toDouble() ?? 0, + (z['zone5_min'] as num?)?.toDouble() ?? 0, + ]; + + @override + Widget build(BuildContext context) { + return _Fetch( + load: (api) => api.getDayHeart(date), + build: (d) { + final hr = (d['hr'] as List?)?.map((e) => ((e as Map)['v'] as num?)?.toDouble() ?? 0).where((v) => v > 0).toList() ?? []; + final rhr = _n(d['resting_hr']); + final rhrBase = _n(d['resting_hr_baseline']); + final rec = _n(d['recovery']); + final hrv = (d['hrv'] as Map?); + final zones = (d['zones'] as Map?); + final noct = (d['nocturnal'] as Map?); + final stress = (d['stress'] as Map?); + final illness = (d['illness'] as Map?); + final resp = (d['resp'] as Map?); + final spo2 = (d['spo2'] as Map?); + final dmap = (d['drivers'] as Map?) ?? const {}; + final heartDrivers = [ + ...((dmap['recovery'] as List?) ?? const []), + ...((dmap['stress'] as List?) ?? const []), + ].whereType().where((dr) => (dr['label']?.toString() ?? '').isNotEmpty).toList(); + + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + // HERO — recovery (HRV) if we have it, else resting HR. + GlowCard( + padding: const EdgeInsets.all(Sp.x6), + child: Row(children: [ + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Row(children: [ + AppIcon(rec != null ? Ic.recovery : Ic.heart, size: 16, color: AppColors.coralDeep), + const SizedBox(width: Sp.x2), + Text(rec != null ? 'RECOVERY' : 'RESTING HR', style: AppText.overline), + if (rec != null) ...[const SizedBox(width: Sp.x2), const Tag('HRV', color: AppColors.good)], + ]), + const SizedBox(height: Sp.x3), + if (rec != null) + Text('${rec.round()}', style: AppText.display) + else if (rhr != null) + Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ + Text('${rhr.round()}', style: AppText.display), + const SizedBox(width: Sp.x2), + Padding(padding: const EdgeInsets.only(bottom: 8), child: Text('bpm', style: AppText.bodySoft)), + ]) + else metricDash(44), + const SizedBox(height: Sp.x2), + Text(rec != null + ? 'HRV-based recovery' + : (rhr != null && rhrBase != null + ? '${(rhr - rhrBase) >= 0 ? '+' : ''}${(rhr - rhrBase).toStringAsFixed(1)} bpm vs baseline' + : 'resting heart rate'), + style: AppText.bodySoft), + ])), + if (rec != null) + RingStat(t: (rec / 100).clamp(0.0, 1.0), color: AppColors.good, size: 96, stroke: 11, + center: Text('${rec.round()}%', style: AppText.metricSm)), + ]), + ), + + if (hr.length > 1) ...[ + const SizedBox(height: Sp.x6), + SectionHeader('Heart rate'), + ProCard(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + AreaSpark(hr, color: AppColors.coral, height: 96), + const SizedBox(height: Sp.x3), + Text('avg ${d['avg_hr'] ?? '—'} · max ${d['max_hr'] ?? '—'} bpm', style: AppText.captionMuted), + ])), + ], + + // HRV — full Task-Force suite, each with what-it-means. + if (hrv != null) ...[ + const SizedBox(height: Sp.x6), + SectionHeader('Heart-rate variability'), + MetricGroup([ + MetricRow(icon: Ic.pulse, accent: AppColors.good, label: 'RMSSD', info: infoFor('rmssd'), + value: '${hrv['rmssd'] ?? '—'}', unit: 'ms'), + if (hrv['sdnn'] != null) + MetricRow(icon: Ic.pulse, accent: AppColors.good, label: 'SDNN', info: infoFor('sdnn'), + value: '${hrv['sdnn']}', unit: 'ms'), + if (hrv['lf_hf'] != null) + MetricRow(icon: Ic.pulse, accent: AppColors.good, label: 'LF / HF', info: infoFor('lf_hf'), + value: '${hrv['lf_hf']}'), + if (hrv['baseline'] != null) + MetricRow(icon: Ic.chart, accent: AppColors.inkSoft, label: 'Your baseline', + info: 'Your typical RMSSD — recovery is measured against this.', + value: '${(_n(hrv['baseline']) ?? 0).round()}', unit: 'ms'), + ]), + ], + + // Stress (HRV-based). + if (stress != null && (stress['si'] != null || stress['score'] != null)) ...[ + const SizedBox(height: Sp.x6), + SectionHeader('Stress'), + MetricGroup([ + MetricRow(icon: Ic.strain, accent: AppColors.warn, label: 'Stress', + info: infoFor('stress'), + value: '${stress['score'] ?? stress['si']}', + valueTag: stress['level'] != null + ? Tag('${stress['level']}'.toUpperCase(), color: AppColors.warn) : null), + if (stress['lf_hf'] != null) + MetricRow(icon: Ic.pulse, accent: AppColors.warn, label: 'Sympatho-vagal balance', + info: infoFor('lf_hf'), value: '${stress['lf_hf']}'), + ]), + ], + + if (zones != null && _zoneVals(zones).fold(0, (s, v) => s + v) > 0) ...[ + const SizedBox(height: Sp.x6), + SectionHeader('HR zones'), + ProCard(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Minutes spent in each effort zone today.', style: AppText.captionMuted), + const SizedBox(height: Sp.x3), + SegmentBar(_zoneVals(zones), _zoneColors, height: 14), + const SizedBox(height: Sp.x3), + Wrap(spacing: Sp.x4, runSpacing: Sp.x2, children: [ + for (int i = 0; i < 5; i++) + Row(mainAxisSize: MainAxisSize.min, children: [ + Container(width: 9, height: 9, decoration: BoxDecoration(color: _zoneColors[i], shape: BoxShape.circle)), + const SizedBox(width: Sp.x2), + Text('Z${i + 1} · ${_zoneVals(zones)[i].round()}m', style: AppText.caption), + ]), + ]), + ])), + ], + + if (noct != null && noct['sleeping_hr_avg'] != null) ...[ + const SizedBox(height: Sp.x6), + SectionHeader('Nocturnal heart'), + MetricGroup([ + MetricRow(icon: Ic.moon, accent: AppColors.loadDetraining, label: 'Sleeping HR', + info: infoFor('sleeping_hr'), value: '${noct['sleeping_hr_avg']}', unit: 'bpm'), + if (noct['dip_pct'] != null) + MetricRow(icon: Ic.down, accent: AppColors.good, label: 'Nocturnal dip', + info: infoFor('nocturnal_dip'), value: '${((noct['dip_pct'] as num) * 100).round()}', unit: '%'), + if (noct['vs_baseline_bpm'] != null) + MetricRow(icon: Ic.chart, accent: AppColors.inkSoft, label: 'vs baseline', + info: 'Tonight vs your typical sleeping heart rate.', + value: '${noct['vs_baseline_bpm']}', unit: 'bpm'), + ]), + ], + + if (resp != null || spo2 != null) ...[ + const SizedBox(height: Sp.x6), + SectionHeader('Respiratory'), + MetricGroup([ + if (resp != null) + MetricRow(icon: Ic.activity, accent: AppColors.good, label: 'Respiratory rate', + info: infoFor('resp'), value: '${resp['value']}', unit: 'brpm'), + if (spo2 != null) + MetricRow(icon: Ic.droplet, accent: AppColors.coralDeep, label: 'Blood-oxygen', + info: infoFor('spo2'), value: '${spo2['value']}', unit: 'Δ'), + ]), + ], + + // Illness — the 3 signals (Mahalanobis). + if (illness != null && illness['signal'] == true) ...[ + const SizedBox(height: Sp.x6), + SectionHeader('Body signal'), + ProCard(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Container( + padding: const EdgeInsets.all(9), + decoration: BoxDecoration(color: AppColors.warnSoft, borderRadius: BorderRadius.circular(R.chip)), + child: const AppIcon(Ic.info, size: 17, color: AppColors.warn), + ), + const SizedBox(width: Sp.x3), + Expanded(child: Text(illness['note']?.toString() ?? 'Elevated body signal', + style: AppText.bodySoft)), + ]), + if ((illness['drivers'] as List?)?.isNotEmpty ?? false) ...[ + const SizedBox(height: Sp.x4), + for (final dr in (illness['drivers'] as List).whereType()) + Padding(padding: const EdgeInsets.only(top: Sp.x2), child: Row(children: [ + const AppIcon(Ic.up, size: 15, color: AppColors.warn), + const SizedBox(width: Sp.x2), + Expanded(child: Text(dr['label']?.toString() ?? '', style: AppText.body)), + Text(dr['detail']?.toString() ?? '', style: AppText.captionMuted), + ])), + ], + ])), + ], + + // What affected this — display-only (no navigation loop), properly padded. + if (heartDrivers.isNotEmpty) ...[ + const SizedBox(height: Sp.x6), + SectionHeader('What affected this'), + ProCard(child: Column(children: [ + for (final dr in heartDrivers) + DetailRow(label: dr['label']?.toString() ?? '', value: dr['detail']?.toString() ?? ''), + ])), + ], + ]); + }, + ); + } +} + +// ── CONCERN EXTRAS: personal records + journal patterns ───────────────────── +// Resurfaces the Records (personal bests) and the journal correlation engine, +// scoped to a concern, shown on its Today tab. Honest descriptive stats only. +const _recordCfg = { + 'sleep': [ + ('longest_sleep', 'Longest sleep', 'dur'), + ('best_efficiency', 'Best efficiency', 'pct'), + ], + 'heart': [ + ('lowest_rhr', 'Lowest resting HR', 'bpm'), + ('lowest_sleeping_hr', 'Lowest sleeping HR', 'bpm'), + ], + 'body': [ + ('top_strain', 'Top strain', 'strain'), + ('most_steps', 'Most steps', 'int'), + ], +}; +const _journalCols = { + 'sleep': ['efficiency', 'duration_min'], + 'heart': ['resting_hr', 'readiness'], + 'body': ['strain'], +}; + +class ConcernExtras extends StatefulWidget { + final String concern; // 'sleep' | 'heart' | 'body' + const ConcernExtras({super.key, required this.concern}); + @override + State createState() => _ConcernExtrasState(); +} + +class _ConcernExtrasState extends State { + Map? _records; + Map? _insights; + bool _loading = true; + + @override + void initState() { + super.initState(); + _go(); + } + + Future _go() async { + final api = context.read().api; + if (api == null) return; + try { + final r = await api.getRecords(); + Map? ins; + try { ins = await api.getJournalInsights(range: '90d'); } catch (_) {} + if (mounted) setState(() { _records = r; _insights = ins; _loading = false; }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + String _fmt(num v, String kind) { + switch (kind) { + case 'dur': return hm(v); + case 'pct': return '${(v * 100).round()}%'; + case 'strain': return v.toStringAsFixed(1); + case 'int': return v.round().toString(); + default: return '${v.round()}'; + } + } + + @override + Widget build(BuildContext context) { + if (_loading) return const SizedBox.shrink(); + final cfg = _recordCfg[widget.concern] ?? const []; + final recs = (_records?['records'] as Map?) ?? const {}; + final tiles = []; + for (final c in cfg) { + final rec = (recs[c.$1] as Map?); + final v = rec == null ? null : (rec['value'] as num?); + if (v == null) continue; + tiles.add(Expanded(child: ProCard( + padding: const EdgeInsets.all(Sp.x4), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(c.$2.toUpperCase(), style: AppText.overline, maxLines: 2), + const SizedBox(height: Sp.x3), + Text(_fmt(v, c.$3), style: AppText.metricSm.copyWith(fontSize: 20)), + ]), + ))); + } + + // Journal patterns relevant to this concern. + final cols = _journalCols[widget.concern] ?? const []; + final patternRows = []; + for (final ins in ((_insights?['insights'] as List?) ?? const [])) { + final tag = (ins as Map)['tag']?.toString() ?? ''; + for (final e in ((ins['effects'] as List?) ?? const [])) { + final em = e as Map; + if (!cols.contains(em['col'])) continue; + final pct = (em['delta_pct'] as num?)?.toDouble() ?? 0; + if (pct.abs() < 3) continue; // skip negligible + final better = em['better'] == true; + patternRows.add(DetailRow( + label: 'On "$tag" days', + value: '${em['label']} ${pct >= 0 ? '+' : ''}${pct.toStringAsFixed(0)}%', + trailing: AppIcon(better ? Ic.up : Ic.down, size: 16, + color: better ? AppColors.good : AppColors.warn), + )); + if (patternRows.length >= 4) break; + } + if (patternRows.length >= 4) break; + } + + if (tiles.isEmpty && patternRows.isEmpty) return const SizedBox.shrink(); + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (tiles.isNotEmpty) ...[ + const SizedBox(height: Sp.x6), + const SectionHeader('Records'), + Row(children: [ + for (int i = 0; i < tiles.length; i++) ...[ + tiles[i], + if (i < tiles.length - 1) const SizedBox(width: Sp.x3), + ], + ]), + ], + if (patternRows.isNotEmpty) ...[ + const SizedBox(height: Sp.x6), + const SectionHeader('Patterns'), + Text('How your tagged days compare — descriptive, not causal.', + style: AppText.captionMuted), + const SizedBox(height: Sp.x2), + ProCard(child: Column(children: patternRows)), + ], + ]); + } +} + +// ── LUNGS ──────────────────────────────────────────────────────────────────── +class LungsDayCard extends StatelessWidget { + final String date; + const LungsDayCard({super.key, required this.date}); + @override + Widget build(BuildContext context) { + return _Fetch( + load: (api) => api.getDayLungs(date), + build: (d) { + final resp = (d['resp'] as Map?) ?? (d['resp_rate'] as Map?); + final spo2 = (d['spo2'] as Map?); + if (resp == null && spo2 == null) { + return ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x5), + child: Center(child: Text('No respiratory data yet\n(needs a clean night of resting RR)', + textAlign: TextAlign.center, style: AppText.captionMuted)))); + } + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (resp != null) + ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x4), child: Row(children: [ + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('RESPIRATORY RATE', style: AppText.overline), + const SizedBox(height: Sp.x2), + Row(crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ + Text('${resp['value']}', style: AppText.metric), + const SizedBox(width: 4), + Text('brpm', style: AppText.caption), + ]), + const SizedBox(height: 2), + const Text('from RR (RSA)', style: TextStyle(fontSize: 11, color: AppColors.inkMuted)), + ])), + const Tag('EST.', color: AppColors.inkMuted), + ]))), + if (spo2 != null) ...[ + const SizedBox(height: Sp.x3), + ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x2), child: Column(children: [ + DetailRow(label: 'Blood-oxygen vs baseline', value: '${spo2['value']} Δ'), + ]))), + ], + ]); + }, + ); + } +} diff --git a/lib/ui/concern/metric_row.dart b/lib/ui/concern/metric_row.dart new file mode 100644 index 0000000..49d6dc3 --- /dev/null +++ b/lib/ui/concern/metric_row.dart @@ -0,0 +1,137 @@ +// MetricRow + metric dictionary — the polished, breathing building block for every +// concern detail. Icon chip + label + a one-line "what this is" + value, with real +// padding. Group several in a MetricGroup (one ProCard, hairline dividers). This is +// what makes the new screens read like the hand-written ones instead of flat lists. + +import 'package:flutter/material.dart'; +import '../../theme/theme.dart'; +import '../../theme/tokens.dart'; +import '../kit/kit.dart'; + +/// One-line, honest explanation per metric key. Shown under the label so users +/// learn what they're looking at without leaving the screen. +const Map kMetricInfo = { + 'recovery': "How recovered you are — tonight's HRV vs your own baseline.", + 'hrv': 'Beat-to-beat variability in sleep. Higher usually means better recovery.', + 'rmssd': 'Beat-to-beat variability in sleep. Higher usually means better recovery.', + 'sdnn': 'Overall heart-rate variability across the night.', + 'lf_hf': 'Balance of stress-related (LF) vs rest (HF) activity.', + 'resting_hr': 'Your lowest heart rate while asleep — a core fitness marker.', + 'stress': 'Sympathetic activation read from your HRV (Baevsky index).', + 'strain': 'Cardiovascular load for the day, on a 0–21 scale.', + 'load': 'Recent (7d) vs habitual (28d) load. 0.8–1.3 is the sweet spot.', + 'fitness': 'Direction of your fitness from resting-HR and recovery trends.', + 'calories': 'Active energy burned, estimated from your heart rate.', + 'steps': 'Estimated steps from wrist motion.', + 'sleep': 'Time actually asleep last night.', + 'efficiency': 'Share of time in bed actually spent asleep.', + 'regularity': 'How consistent your sleep timing is, 0–100.', + 'deep': 'Deepest, most physically restorative sleep.', + 'rem': 'Dreaming sleep — mental restoration and memory.', + 'light': 'The bridge between deeper stages; most of the night.', + 'nocturnal_dip': 'How far your heart rate falls in sleep — a bigger dip is better.', + 'sleeping_hr': 'Average heart rate while you slept.', + 'resp': 'Breaths per minute, derived from heart-rate variability.', + 'spo2': 'Blood-oxygen relative to your own baseline.', + 'hrr60': 'How fast your HR drops a minute after peak effort — fitness marker.', + 'illness': 'A combined resting-HR / HRV / temperature signal that can flag early illness.', + 'debt': 'Sleep you owe from falling short of your need on recent nights.', +}; + +String? infoFor(String key) => kMetricInfo[key]; + +/// A single metric line: [icon chip] label + description ........ value unit [›] +class MetricRow extends StatelessWidget { + final IconData icon; + final Color accent; + final String label; + final String? info; + final String value; + final String? unit; + final Widget? valueTag; // e.g. a Tag chip beside the value + final VoidCallback? onTap; + const MetricRow({ + super.key, + required this.icon, + required this.label, + required this.value, + this.info, + this.unit, + this.accent = AppColors.coral, + this.valueTag, + this.onTap, + }); + @override + Widget build(BuildContext context) { + final row = Padding( + padding: const EdgeInsets.symmetric(vertical: Sp.x3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Top line: icon chip + label .......... value unit [tag] [›] + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(R.chip), + ), + child: AppIcon(icon, size: 17, color: accent), + ), + const SizedBox(width: Sp.x3), + Expanded(child: Text(label, style: AppText.label)), + const SizedBox(width: Sp.x3), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + mainAxisSize: MainAxisSize.min, + children: [ + Text(value, style: AppText.metricSm.copyWith(fontSize: 19)), + if (unit != null) ...[ + const SizedBox(width: 3), + Text(unit!, style: AppText.caption), + ], + ], + ), + if (valueTag != null) ...[const SizedBox(width: Sp.x2), valueTag!], + if (onTap != null) ...[ + const SizedBox(width: Sp.x2), + const AppIcon(Ic.arrowRight, size: 16, color: AppColors.inkMuted), + ], + ], + ), + // Description on its OWN full-width line (indented under the chip) so it + // never gets squeezed/cut by the value column. + if (info != null) ...[ + const SizedBox(height: Sp.x2), + Padding( + padding: const EdgeInsets.only(left: 38), // chip width + gap + child: Text(info!, style: AppText.captionMuted), + ), + ], + ], + ), + ); + if (onTap == null) return row; + return InkWell(borderRadius: BorderRadius.circular(R.cardSm), onTap: onTap, child: row); + } +} + +/// A group of MetricRows in one card with hairline dividers between them. +class MetricGroup extends StatelessWidget { + final List rows; + const MetricGroup(this.rows, {super.key}); + @override + Widget build(BuildContext context) { + final children = []; + for (int i = 0; i < rows.length; i++) { + children.add(rows[i]); + if (i < rows.length - 1) { + children.add(const Divider(height: 1, thickness: 1, color: AppColors.divider)); + } + } + return ProCard(child: Column(children: children)); + } +} diff --git a/lib/ui/kit/charts.dart b/lib/ui/kit/charts.dart index 3fca806..e374002 100644 --- a/lib/ui/kit/charts.dart +++ b/lib/ui/kit/charts.dart @@ -124,12 +124,17 @@ class MiniBars extends StatelessWidget { } /// Labeled bar chart (e.g. weekly goal, Mon..Sun) — big rounded coral bars. +/// Shows the numeric value above each bar (set [showValues] false to hide). +/// [onTapBar] makes a bar tappable (drill-down in the Metric Explorer). class LabeledBars extends StatelessWidget { final List values; final List labels; final Color color; final double height; final int? highlight; + final bool showValues; + final String Function(double v)? valueFmt; // how to render the number + final void Function(int i)? onTapBar; const LabeledBars({ super.key, required this.values, @@ -137,7 +142,18 @@ class LabeledBars extends StatelessWidget { this.color = AppColors.coral, this.height = 200, this.highlight, + this.showValues = true, + this.valueFmt, + this.onTapBar, }); + + String _fmt(double v) { + if (valueFmt != null) return valueFmt!(v); + // tidy default: integers when whole, else one decimal; blank for exact 0. + if (v == 0) return ''; + return v == v.roundToDouble() ? v.toStringAsFixed(0) : v.toStringAsFixed(1); + } + @override Widget build(BuildContext context) { final maxV = values.isEmpty ? 1.0 : math.max(1.0, values.reduce(math.max)); @@ -148,34 +164,49 @@ class LabeledBars extends StatelessWidget { children: [ for (int i = 0; i < values.length; i++) Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Expanded( - child: TweenAnimationBuilder( - duration: Motion.med, - curve: Motion.emphatic, - tween: Tween(begin: 0, end: values[i] / maxV), - builder: (_, v, _) => FractionallySizedBox( - heightFactor: v.clamp(0.02, 1.0), - alignment: Alignment.bottomCenter, - child: Container( - decoration: BoxDecoration( + child: GestureDetector( + onTap: onTapBar == null ? null : () => onTapBar!(i), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (showValues) + Text(_fmt(values[i]), + style: AppText.caption.copyWith( + fontWeight: FontWeight.w600, color: (highlight == null || highlight == i) - ? color - : color.withValues(alpha: 0.28), - borderRadius: BorderRadius.circular(R.pill), + ? AppColors.ink + : AppColors.inkMuted, + ), + maxLines: 1, + overflow: TextOverflow.visible), + if (showValues) const SizedBox(height: 2), + Expanded( + child: TweenAnimationBuilder( + duration: Motion.med, + curve: Motion.emphatic, + tween: Tween(begin: 0, end: values[i] / maxV), + builder: (_, v, _) => FractionallySizedBox( + heightFactor: v.clamp(0.02, 1.0), + alignment: Alignment.bottomCenter, + child: Container( + decoration: BoxDecoration( + color: (highlight == null || highlight == i) + ? color + : color.withValues(alpha: 0.28), + borderRadius: BorderRadius.circular(R.pill), + ), ), ), ), ), - ), - const SizedBox(height: Sp.x2), - Text(labels.length > i ? labels[i] : '', - style: AppText.caption), - ], + const SizedBox(height: Sp.x2), + Text(labels.length > i ? labels[i] : '', + style: AppText.caption), + ], + ), ), ), ), diff --git a/lib/ui/sleep/sleep_detail_screen.dart b/lib/ui/sleep/sleep_detail_screen.dart index 4821544..b2c7a68 100644 --- a/lib/ui/sleep/sleep_detail_screen.dart +++ b/lib/ui/sleep/sleep_detail_screen.dart @@ -13,7 +13,11 @@ import '../kit/charts.dart'; class SleepDetailScreen extends StatefulWidget { final String date; // 'YYYY-MM-DD' - const SleepDetailScreen({super.key, required this.date}); + // When true, render just the section content (no Scaffold/back bar) so it can be + // embedded inside the Sleep ConcernScreen (Today/Week/Month/3M). This is the EXACT + // same loved layout — only the chrome differs. + final bool embedded; + const SleepDetailScreen({super.key, required this.date, this.embedded = false}); /// Convenience: a detail screen for today (local). factory SleepDetailScreen.today({Key? key}) { @@ -226,8 +230,50 @@ class _SleepDetailScreenState extends State { // ── build ────────────────────────────────────────────────────────────────── + /// The night's sections (phase-aware). Shared by the standalone screen and the + /// embedded (ConcernScreen) mode so the layout is identical. + List _sections() { + if (_phase == _Phase.loading) return [_loading()]; + if (_phase == _Phase.empty) { + return [ + _stateCard(Ic.moon, 'No sleep recorded for this night', + 'Wear your strap overnight and sync. Your sleep breakdown will ' + 'appear here once a night has been recorded.'), + ]; + } + if (_phase == _Phase.error) { + return [_stateCard(Ic.cloud, "Couldn't load this night", _error ?? 'Please try again.')]; + } + return [ + _hero(), + const SizedBox(height: Sp.x6), + _hypnogramCard(), + const SizedBox(height: Sp.x6), + SectionHeader('Stages'), + _stageBreakdown(), + const SizedBox(height: Sp.x6), + SectionHeader('Efficiency'), + _efficiencyCard(), + const SizedBox(height: Sp.x6), + SectionHeader('Sleep debt'), + _debtCard(), + const SizedBox(height: Sp.x6), + SectionHeader('Consistency'), + _consistencyCard(), + if (_hasNocturnal) ...[ + const SizedBox(height: Sp.x6), + SectionHeader('Nocturnal heart'), + _nocturnalCard(), + ], + ]; + } + @override Widget build(BuildContext context) { + // Embedded in the Sleep ConcernScreen: just the sections (its ListView scrolls). + if (widget.embedded) { + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: _sections()); + } return Scaffold( backgroundColor: AppColors.bg, body: SafeArea( @@ -238,43 +284,7 @@ class _SleepDetailScreenState extends State { const SizedBox(height: Sp.x4), _topBar(), const SizedBox(height: Sp.x6), - if (_phase == _Phase.loading) - _loading() - else if (_phase == _Phase.empty) - _stateCard( - Ic.moon, - 'No sleep recorded for this night', - 'Wear your strap overnight and sync. Your sleep breakdown will ' - 'appear here once a night has been recorded.', - ) - else if (_phase == _Phase.error) - _stateCard( - Ic.cloud, - "Couldn't load this night", - _error ?? 'Please try again.', - ) - else ...[ - _hero(), - const SizedBox(height: Sp.x6), - _hypnogramCard(), - const SizedBox(height: Sp.x6), - SectionHeader('Stages'), - _stageBreakdown(), - const SizedBox(height: Sp.x6), - SectionHeader('Efficiency'), - _efficiencyCard(), - const SizedBox(height: Sp.x6), - SectionHeader('Sleep debt'), - _debtCard(), - const SizedBox(height: Sp.x6), - SectionHeader('Consistency'), - _consistencyCard(), - if (_hasNocturnal) ...[ - const SizedBox(height: Sp.x6), - SectionHeader('Nocturnal heart'), - _nocturnalCard(), - ], - ], + ..._sections(), const SizedBox(height: 40), ], ), diff --git a/lib/ui/today/today_screen.dart b/lib/ui/today/today_screen.dart index 8c70b64..b1eb47c 100644 --- a/lib/ui/today/today_screen.dart +++ b/lib/ui/today/today_screen.dart @@ -15,9 +15,8 @@ import '../widgets/screen_loader.dart'; import '../journal/journal_screen.dart'; import '../recap/recap_screen.dart'; import '../coach/coach_screen.dart'; -import '../recovery/recovery_screen.dart'; -import '../activity/strain_detail_screen.dart'; -import '../sleep/sleep_detail_screen.dart'; +import '../profile/profile_screen.dart'; +import '../concern/concern_screens.dart'; import '../journey/journey_screen.dart'; import '../stress/stress_screen.dart'; import '../records/records_screen.dart'; @@ -174,6 +173,12 @@ class _TodayScreenState extends State const SizedBox(width: Sp.x2), RoundIconButton(Ic.edit, onTap: () => _push(const JournalScreen())), const SizedBox(width: Sp.x2), + // Profile / settings (the old "You" tab moved here). ProfileScreen is tab + // content (no Scaffold of its own), so wrap it when pushing standalone — + // otherwise it renders with no Material (black bg + yellow-underlined text). + RoundIconButton(Ic.profile, onTap: () => _push( + const Scaffold(backgroundColor: AppColors.bg, body: ProfileScreen()))), + const SizedBox(width: Sp.x2), RoundIconButton(Ic.chart, bg: AppColors.coral, fg: Colors.white, @@ -237,17 +242,6 @@ class _TodayScreenState extends State // ── content ────────────────────────────────────────────────────────────────── List _content(TodayData t) { - // Hero: readiness, falling back to strain. - final readiness = t.readiness; - final strain = t.strain; - final useReadiness = !readiness.isEmpty; - final hero = useReadiness ? readiness : strain; - final heroMax = useReadiness ? 100.0 : 21.0; - final heroLabel = useReadiness ? 'Readiness' : 'Day strain'; - final heroT = hero.normalized(heroMax); - final heroColor = - heroT.isNaN ? AppColors.inkMuted : AppColors.scoreColor(heroT); - final alert = t.bodyAlert; final coach = t.coach; final date = _todayStr(); @@ -257,17 +251,7 @@ class _TodayScreenState extends State _bodyAlert(alert), const SizedBox(height: Sp.x4), ], - _hero(hero, heroLabel, heroMax, heroColor, - onTap: useReadiness && coach != null - ? () => _push(RecoveryScreen( - readiness: readiness.value, - confidence: readiness.confidence, - contributors: coach.contributors, - )) - : null), - const SizedBox(height: Sp.x4), - - // At-a-glance gauges: Strain / Sleep / HRV alongside the readiness hero. + // At-a-glance gauges: Strain / Sleep / HRV. _dashboard(t), const SizedBox(height: Sp.x4), @@ -277,19 +261,9 @@ class _TodayScreenState extends State const SizedBox(height: Sp.x4), ], - // Stat grid. + // Stat grid. Strain lives on the Body tab now (tap the Strain gauge above) — + // no duplicate Day-strain tile here. _statRow( - StatTile( - icon: Ic.strain, - label: 'Day strain', - value: t.strain.isEmpty - ? null - : t.strain.value!.toStringAsFixed(1), - accent: AppColors.coral, - confidence: t.strain.isEmpty ? null : t.strain.confidence, - tag: Tag.forMetric(t.strain), - onTap: () => _push(StrainDetailScreen(date: date)), - ), StatTile( icon: Ic.heart, label: 'Resting HR', @@ -300,9 +274,6 @@ class _TodayScreenState extends State accent: AppColors.coralDeep, confidence: t.restingHr.isEmpty ? null : t.restingHr.confidence, ), - ), - const SizedBox(height: Sp.x3), - _statRow( StatTile( icon: Ic.fire, label: 'Active calories', @@ -312,16 +283,6 @@ class _TodayScreenState extends State confidence: t.calories.isEmpty ? null : t.calories.confidence, tag: Tag.forMetric(t.calories), ), - StatTile( - icon: Ic.moon, - label: 'Sleep', - value: _hm(t.sleepDuration), - accent: AppColors.loadDetraining, - confidence: - t.sleepDuration.isEmpty ? null : t.sleepDuration.confidence, - tag: Tag.forMetric(t.sleepDuration), - onTap: () => _push(SleepDetailScreen(date: date)), - ), ), const SizedBox(height: Sp.x3), _statRow( @@ -352,13 +313,7 @@ class _TodayScreenState extends State tag: const Tag('est.', color: AppColors.coral), onTap: () => _push(StressScreen(date: date)), ), - _bodyOverTimeTile(), - ), - const SizedBox(height: Sp.x3), - - // HRV (measured, beat-to-beat) + relative blood-oxygen. New: HRV is the real - // one now that we decode R-R intervals; SpO₂ is a relative index (raw, beta). - _statRow( + // HRV (measured, beat-to-beat). The real one now that we decode R-R intervals. StatTile( icon: Ic.pulse, label: 'HRV (RMSSD)', @@ -368,6 +323,9 @@ class _TodayScreenState extends State confidence: t.hrv?.confidence, tag: const Tag('beta', color: AppColors.coral), ), + ), + const SizedBox(height: Sp.x3), + _statRow( StatTile( icon: Ic.heart, label: 'Blood O₂ (rel.)', @@ -378,6 +336,7 @@ class _TodayScreenState extends State accent: AppColors.coralDeep, tag: const Tag('beta', color: AppColors.coral), ), + _bodyOverTimeTile(), ), const SizedBox(height: Sp.x4), @@ -482,19 +441,26 @@ class _TodayScreenState extends State child: Row( children: [ _gauge('STRAIN', t.strain.isEmpty ? null : t.strain.value!.toStringAsFixed(1), - null, strainT, AppColors.coral), + null, strainT, AppColors.coral, + onTap: () => _push(const BodyConcernScreen())), _gauge('SLEEP', t.sleepDuration.isEmpty ? null : (t.sleepDuration.value! / 60).toStringAsFixed(1), - 'h', sleepT, AppColors.loadDetraining), + 'h', sleepT, AppColors.loadDetraining, + onTap: () => _push(const SleepConcernScreen())), _gauge('HRV', hrv == null ? null : hrv.rmssd.toStringAsFixed(0), - 'ms', hrvT, AppColors.good), + 'ms', hrvT, AppColors.good, + onTap: () => _push(const HeartConcernScreen())), ], ), ); } - Widget _gauge(String label, String? value, String? unit, double t, Color color) { + Widget _gauge(String label, String? value, String? unit, double t, Color color, + {VoidCallback? onTap}) { return Expanded( - child: Column( + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Column( mainAxisSize: MainAxisSize.min, children: [ RingStat( @@ -516,52 +482,6 @@ class _TodayScreenState extends State const SizedBox(height: Sp.x2), Text(label, style: AppText.overline), ], - ), - ); - } - - Widget _hero(Metric hero, String label, double max, Color color, - {VoidCallback? onTap}) { - final dash = hero.isEmpty; - return GlowCard( - onTap: onTap, - padding: const EdgeInsets.symmetric(vertical: Sp.x7, horizontal: Sp.x5), - child: Center( - child: RingStat( - t: hero.normalized(max), - color: color, - size: 208, - stroke: 16, - center: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (dash) - metricDash(48) - else - Text( - label == 'Readiness' - ? hero.value!.round().toString() - : hero.value!.toStringAsFixed(1), - style: AppText.display.copyWith(color: color), - ), - const SizedBox(height: Sp.x2), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (!dash) ...[ - ConfDot(hero.confidence), - const SizedBox(width: 6), - ], - Text(label.toUpperCase(), - style: AppText.overline), - ], - ), - if (Tag.forMetric(hero) != null) ...[ - const SizedBox(height: Sp.x2), - Tag.forMetric(hero)!, - ], - ], - ), ), ), ); @@ -680,18 +600,7 @@ class _TodayScreenState extends State } List _skeleton() => [ - ProCard( - padding: const EdgeInsets.symmetric(vertical: Sp.x7), - child: Center( - child: RingStat( - t: double.nan, - color: AppColors.inkMuted, - size: 208, - stroke: 16, - center: metricDash(48), - ), - ), - ), + const ProCard(child: SizedBox(height: 96)), const SizedBox(height: Sp.x4), _statRow(_skelTile(), _skelTile()), const SizedBox(height: Sp.x3), diff --git a/lib/widget/widget_service.dart b/lib/widget/widget_service.dart index 71cae23..014db96 100644 --- a/lib/widget/widget_service.dart +++ b/lib/widget/widget_service.dart @@ -32,7 +32,7 @@ class WidgetService { static Future push(TodayData t) async { try { await init(); - final r = t.readiness; + final r = t.recovery; final s = t.strain; final sleep = t.sleepDuration; final need = t.sleepNeed; From 59722ac8264e003d2b6a025406cf7ce267766e1f Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 14 Jun 2026 04:02:58 +0530 Subject: [PATCH 05/55] =?UTF-8?q?WIP:=20workouts=20page=20(exercise=20pick?= =?UTF-8?q?er=20=E2=86=92=20live=20start/stop,=20list=20+=20summary,=20det?= =?UTF-8?q?ail,=20auto-detect=20tags)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/ui/workouts/workouts_screen.dart | 365 +++++++++++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100644 lib/ui/workouts/workouts_screen.dart diff --git a/lib/ui/workouts/workouts_screen.dart b/lib/ui/workouts/workouts_screen.dart new file mode 100644 index 0000000..2531b1b --- /dev/null +++ b/lib/ui/workouts/workouts_screen.dart @@ -0,0 +1,365 @@ +// Workouts — the training log. Manual start (▶ → pick type → live → end → breakdown) +// and auto-detected efforts both land here. Per timeframe we show an honest training +// summary (time/count/type/zones/calories — no fabricated distance or reps) + the +// list; tap a workout for its full breakdown. Reuses the existing kit. + +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../state/app_state.dart'; +import '../../theme/theme.dart'; +import '../../theme/tokens.dart'; +import '../kit/kit.dart'; +import '../kit/charts.dart'; +import '../concern/detail_cards.dart' show hm; + +const _exercises = [ + ('run', 'Run', Ic.run), ('cycle', 'Cycle', Ic.activity), ('strength', 'Strength', Ic.fire), + ('walk', 'Walk', Ic.run), ('swim', 'Swim', Ic.activity), ('cardio', 'Cardio', Ic.pulse), + ('yoga', 'Yoga', Ic.heart), ('other', 'Other', Ic.activity), +]; +const _ranges = ['Today', 'Week', 'Month', '3M']; +const _rangeKey = ['week', 'week', 'month', 'quarter']; // Today filters week to today + +/// Bottom-sheet exercise picker → starts a workout → opens the live screen. +Future startWorkoutFlow(BuildContext context) async { + final type = await showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(R.card))), + builder: (_) => Padding( + padding: const EdgeInsets.all(Sp.x5), + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Start a workout', style: AppText.h2), + const SizedBox(height: Sp.x4), + Wrap(spacing: Sp.x3, runSpacing: Sp.x3, children: [ + for (final e in _exercises) + GestureDetector( + onTap: () => Navigator.pop(context, e.$1), + child: Container( + width: 96, padding: const EdgeInsets.symmetric(vertical: Sp.x4), + decoration: BoxDecoration(color: AppColors.surfaceAlt, borderRadius: BorderRadius.circular(R.card)), + child: Column(children: [ + AppIcon(e.$3, size: 26, color: AppColors.coral), + const SizedBox(height: Sp.x2), + Text(e.$2, style: AppText.label), + ]), + ), + ), + ]), + const SizedBox(height: Sp.x4), + ]), + ), + ); + if (type == null || !context.mounted) return; + final api = context.read().api; + if (api == null) return; + try { + final w = await api.startWorkout(type); + if (!context.mounted) return; + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => LiveWorkoutScreen(workoutId: w['workout_id'] as String, type: type), + )); + } catch (_) {/* surfaced as no-op; user can retry */} +} + +class WorkoutsScreen extends StatefulWidget { + const WorkoutsScreen({super.key}); + @override + State createState() => _WorkoutsScreenState(); +} + +class _WorkoutsScreenState extends State { + int _range = 0; + Map? _data; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final api = context.read().api; + if (api == null) return; + setState(() => _loading = true); + try { + final d = await api.getWorkouts(range: _rangeKey[_range]); + if (mounted) setState(() { _data = d; _loading = false; }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + bool _isToday(int startTs) { + final now = DateTime.now().toUtc(); + final d = DateTime.fromMillisecondsSinceEpoch(startTs * 1000, isUtc: true); + return d.year == now.year && d.month == now.month && d.day == now.day; + } + + @override + Widget build(BuildContext context) { + final all = (_data?['workouts'] as List?) ?? const []; + final list = _range == 0 ? all.where((w) => _isToday((w as Map)['start_ts'] as int? ?? 0)).toList() : all; + final summary = (_data?['summary'] as Map?)?.cast(); + return Scaffold( + backgroundColor: AppColors.bg, + floatingActionButton: FloatingActionButton.extended( + backgroundColor: AppColors.coral, + onPressed: () => startWorkoutFlow(context).then((_) => _load()), + icon: const AppIcon(Ic.run, size: 20, color: Colors.white), + label: Text('Start', style: AppText.label.copyWith(color: Colors.white)), + ), + body: SafeArea( + child: RefreshIndicator( + onRefresh: _load, + child: ListView( + padding: const EdgeInsets.fromLTRB(Sp.x4, Sp.x4, Sp.x4, Sp.x10), + children: [ + Row(children: [ + if (Navigator.of(context).canPop()) ...[ + RoundIconButton(Ic.arrowLeft, onTap: () => Navigator.of(context).maybePop()), + const SizedBox(width: Sp.x3), + ], + Text('Workouts', style: AppText.h1), + ]), + const SizedBox(height: Sp.x3), + Center(child: SegToggle(options: _ranges, index: _range, onChanged: (i) { setState(() => _range = i); _load(); })), + const SizedBox(height: Sp.x4), + if (_loading) + const Padding(padding: EdgeInsets.symmetric(vertical: Sp.x6), child: Center(child: CircularProgressIndicator())) + else ...[ + if (_range != 0 && summary != null) _summary(summary), + if (_range != 0 && summary != null) const SizedBox(height: Sp.x4), + if (list.isEmpty) + ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x6), child: Center( + child: Column(children: [ + const AppIcon(Ic.run, size: 32, color: AppColors.inkMuted), + const SizedBox(height: Sp.x3), + Text('No workouts', style: AppText.label), + const SizedBox(height: Sp.x1), + Text('Tap Start, or an effort will be auto-detected.', style: AppText.captionMuted, textAlign: TextAlign.center), + ]), + ))) + else + for (final w in list) _WorkoutTile(w as Map), + ], + ], + ), + ), + ), + ); + } + + Widget _summary(Map s) { + final byType = (s['by_type'] as Map?) ?? const {}; + final types = byType.entries.map((e) => '${(e.value as Map)['count']} ${e.key}').join(' · '); + return ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x4), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + _stat('${s['count'] ?? 0}', 'workouts'), + const SizedBox(width: Sp.x5), + _stat(hm(s['total_min'] as num?), 'active time'), + const SizedBox(width: Sp.x5), + _stat('${s['total_calories'] ?? 0}', 'kcal'), + ]), + if (types.isNotEmpty) ...[ + const SizedBox(height: Sp.x3), + Text(types, style: AppText.captionMuted), + ], + ]))); + } + + Widget _stat(String v, String label) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(v, style: AppText.h2), + Text(label, style: AppText.captionMuted), + ]); +} + +class _WorkoutTile extends StatelessWidget { + final Map w; + const _WorkoutTile(this.w); + @override + Widget build(BuildContext context) { + final live = w['status'] == 'live'; + return Padding( + padding: const EdgeInsets.only(bottom: Sp.x3), + child: ProCard( + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => WorkoutDetailScreen(id: w['id'] as String))), + padding: const EdgeInsets.all(Sp.x4), + child: Row(children: [ + Container(padding: const EdgeInsets.all(10), + decoration: BoxDecoration(color: AppColors.coral.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(R.chip)), + child: const AppIcon(Ic.run, size: 18, color: AppColors.coral)), + const SizedBox(width: Sp.x3), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Text('${w['type']}'.toUpperCase(), style: AppText.label), + if (w['source'] == 'auto') ...[const SizedBox(width: Sp.x2), const Tag('AUTO', color: AppColors.inkMuted)], + if (live) ...[const SizedBox(width: Sp.x2), const Tag('LIVE', color: AppColors.coral)], + ]), + const SizedBox(height: 2), + Text('${hm(w['duration_min'] as num?)} · ${w['avg_hr'] ?? '—'} bpm · strain ${w['strain'] ?? '—'}', + style: AppText.captionMuted), + ])), + const AppIcon(Icons.chevron_right, size: 18, color: AppColors.inkMuted), + ]), + ), + ); + } +} + +/// Live workout — elapsed timer + recording state + Stop. The actual data records +/// via the existing background BLE keepalive (foreground service / iOS restoration), +/// so it keeps recording even if the app is backgrounded; the breakdown is computed +/// server-side on Stop regardless. +class LiveWorkoutScreen extends StatefulWidget { + final String workoutId; + final String type; + const LiveWorkoutScreen({super.key, required this.workoutId, required this.type}); + @override + State createState() => _LiveWorkoutScreenState(); +} + +class _LiveWorkoutScreenState extends State { + late final DateTime _start = DateTime.now(); + Timer? _t; + bool _ending = false; + + @override + void initState() { + super.initState(); + _t = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) setState(() {}); }); + } + + @override + void dispose() { _t?.cancel(); super.dispose(); } + + String get _elapsed { + final s = DateTime.now().difference(_start).inSeconds; + final h = s ~/ 3600, m = (s % 3600) ~/ 60, sec = s % 60; + return h > 0 ? '$h:${m.toString().padLeft(2, '0')}:${sec.toString().padLeft(2, '0')}' + : '$m:${sec.toString().padLeft(2, '0')}'; + } + + Future _stop() async { + final api = context.read().api; + if (api == null) return; + setState(() => _ending = true); + try { + await api.endWorkout(widget.workoutId); + if (!mounted) return; + Navigator.of(context).pushReplacement(MaterialPageRoute( + builder: (_) => WorkoutDetailScreen(id: widget.workoutId))); + } catch (_) { + if (mounted) setState(() => _ending = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.night, + body: SafeArea(child: Padding(padding: const EdgeInsets.all(Sp.x5), child: Column(children: [ + const Spacer(), + Text(widget.type.toUpperCase(), style: AppText.overline.copyWith(color: AppColors.onNightSoft)), + const SizedBox(height: Sp.x3), + Text(_elapsed, style: AppText.hero.copyWith(color: AppColors.onNight)), + const SizedBox(height: Sp.x3), + Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Container(width: 8, height: 8, decoration: const BoxDecoration(color: AppColors.coral, shape: BoxShape.circle)), + const SizedBox(width: Sp.x2), + Text('Recording', style: AppText.body.copyWith(color: AppColors.onNightSoft)), + ]), + const SizedBox(height: Sp.x4), + Text('Keeps recording in the background — your data is safe even if you leave the app.', + textAlign: TextAlign.center, style: AppText.caption.copyWith(color: AppColors.onNightSoft)), + const Spacer(), + SizedBox(width: double.infinity, child: FilledButton( + style: FilledButton.styleFrom(backgroundColor: AppColors.coral, padding: const EdgeInsets.symmetric(vertical: Sp.x4)), + onPressed: _ending ? null : _stop, + child: Text(_ending ? 'Finishing…' : 'Stop workout', style: AppText.label.copyWith(color: Colors.white)), + )), + const SizedBox(height: Sp.x4), + ]))), + ); + } +} + +/// Post-workout breakdown (also the tap target from the list). +class WorkoutDetailScreen extends StatelessWidget { + final String id; + const WorkoutDetailScreen({super.key, required this.id}); + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.bg, + appBar: AppBar(backgroundColor: AppColors.bg, elevation: 0, title: Text('Workout', style: AppText.title)), + body: _WorkoutDetailBody(id: id), + ); + } +} + +class _WorkoutDetailBody extends StatefulWidget { + final String id; + const _WorkoutDetailBody({required this.id}); + @override + State<_WorkoutDetailBody> createState() => _WorkoutDetailBodyState(); +} + +class _WorkoutDetailBodyState extends State<_WorkoutDetailBody> { + Map? _d; + bool _loading = true; + @override + void initState() { super.initState(); _go(); } + Future _go() async { + final api = context.read().api; + if (api == null) return; + try { final d = await api.getWorkout(widget.id); if (mounted) setState(() { _d = d; _loading = false; }); } + catch (_) { if (mounted) setState(() => _loading = false); } + } + @override + Widget build(BuildContext context) { + if (_loading) return const Center(child: CircularProgressIndicator()); + final d = _d; + if (d == null) return Center(child: Text('Not found', style: AppText.captionMuted)); + final hr = (d['hr'] as List?)?.map((e) => ((e as Map)['v'] as num?)?.toDouble() ?? 0).where((v) => v > 0).toList() ?? []; + final z = (d['zones'] as Map?); + return ListView(padding: const EdgeInsets.all(Sp.x4), children: [ + ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x4), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('${d['type']}'.toUpperCase(), style: AppText.overline), + const SizedBox(height: Sp.x2), + Text(hm(d['duration_min'] as num?), style: AppText.metric), + const SizedBox(height: Sp.x2), + Text('avg ${d['avg_hr'] ?? '—'} · max ${d['max_hr'] ?? '—'} bpm · strain ${d['strain'] ?? '—'} · ${d['calories'] ?? 0} kcal', + style: AppText.captionMuted), + ]))), + if (hr.length > 1) ...[ + const SizedBox(height: Sp.x3), + ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x4), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Heart rate', style: AppText.label), + const SizedBox(height: Sp.x2), + AreaSpark(hr, color: AppColors.coral, height: 100), + ]))), + ], + if (z != null) ...[ + const SizedBox(height: Sp.x3), + ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x4), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('HR zones', style: AppText.label), + const SizedBox(height: Sp.x3), + SegmentBar([ + (z['zone1_min'] as num?)?.toDouble() ?? 0, (z['zone2_min'] as num?)?.toDouble() ?? 0, + (z['zone3_min'] as num?)?.toDouble() ?? 0, (z['zone4_min'] as num?)?.toDouble() ?? 0, + (z['zone5_min'] as num?)?.toDouble() ?? 0, + ], const [AppColors.cool, AppColors.loadDetraining, AppColors.good, AppColors.warn, AppColors.coral], height: 14), + ]))), + ], + if (d['hrr60'] != null) ...[ + const SizedBox(height: Sp.x3), + ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x2), child: DetailRow(label: 'HR recovery (60s)', value: '${d['hrr60']} bpm'))), + ], + ]); + } +} From 6d51297b5db466b24ed459937ba6f740244f7fbf Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 14 Jun 2026 13:52:50 +0530 Subject: [PATCH 06/55] =?UTF-8?q?edge:=20rename=20Concern=E2=86=92Metric/S?= =?UTF-8?q?leep/Heart/Body=20screens,=20drop=20unused=20screens,=20always-?= =?UTF-8?q?show=20illness=20watch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/ui/concern → lib/ui/screens; ConcernScreen→MetricScreen, *ConcernScreen→Sleep/Heart/BodyScreen, ConcernExtras→SectionExtras (section: param) - delete orphan screens: sleep_screen, recovery_screen, trends_screen; drop dead SleepDayCard/LungsDayCard - Illness watch now always visible in Heart screen: active-signal / all-clear / building-baseline states --- lib/app.dart | 8 +- lib/ui/activity/strain_detail_screen.dart | 2 +- lib/ui/recovery/recovery_screen.dart | 144 ----- lib/ui/{concern => screens}/detail_cards.dart | 248 ++++----- lib/ui/{concern => screens}/metric_row.dart | 4 +- .../metric_screen.dart} | 14 +- .../screens.dart} | 32 +- lib/ui/sleep/sleep_detail_screen.dart | 6 +- lib/ui/sleep/sleep_screen.dart | 451 ---------------- lib/ui/today/today_screen.dart | 8 +- lib/ui/trends/trends_screen.dart | 496 ------------------ lib/ui/workouts/workouts_screen.dart | 2 +- 12 files changed, 130 insertions(+), 1285 deletions(-) delete mode 100644 lib/ui/recovery/recovery_screen.dart rename lib/ui/{concern => screens}/detail_cards.dart (64%) rename lib/ui/{concern => screens}/metric_row.dart (98%) rename lib/ui/{concern/concern_screen.dart => screens/metric_screen.dart} (95%) rename lib/ui/{concern/concern_screens.dart => screens/screens.dart} (69%) delete mode 100644 lib/ui/sleep/sleep_screen.dart delete mode 100644 lib/ui/trends/trends_screen.dart diff --git a/lib/app.dart b/lib/app.dart index df53e9c..57ee63d 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -9,7 +9,7 @@ import 'ui/kit/kit.dart'; import 'ui/onboarding_screens.dart'; import 'ui/pairing_screen.dart'; import 'ui/today/today_screen.dart'; -import 'ui/concern/concern_screens.dart'; +import 'ui/screens/screens.dart'; import 'ui/workouts/workouts_screen.dart'; class OpenStrapApp extends StatefulWidget { @@ -86,9 +86,9 @@ class _ShellState extends State<_Shell> { static const _pages = [ TodayScreen(), - SleepConcernScreen(), - HeartConcernScreen(), - BodyConcernScreen(), + SleepScreen(), + HeartScreen(), + BodyScreen(), WorkoutsScreen(), ]; diff --git a/lib/ui/activity/strain_detail_screen.dart b/lib/ui/activity/strain_detail_screen.dart index e3be0d3..e4b4154 100644 --- a/lib/ui/activity/strain_detail_screen.dart +++ b/lib/ui/activity/strain_detail_screen.dart @@ -13,7 +13,7 @@ import '../kit/charts.dart'; class StrainDetailScreen extends StatefulWidget { final String date; // 'YYYY-MM-DD' - // Embedded (no Scaffold/back bar) for use inside the Body ConcernScreen. + // Embedded (no Scaffold/back bar) for use inside the Body screen. final bool embedded; const StrainDetailScreen({super.key, required this.date, this.embedded = false}); @override diff --git a/lib/ui/recovery/recovery_screen.dart b/lib/ui/recovery/recovery_screen.dart deleted file mode 100644 index a8cfb65..0000000 --- a/lib/ui/recovery/recovery_screen.dart +++ /dev/null @@ -1,144 +0,0 @@ -// Recovery detail — the readiness score broken into resting HR, sleep, and -// quality. Not HRV-based. - -import 'package:flutter/material.dart'; - -import '../../models/payloads.dart'; -import '../../theme/theme.dart'; -import '../../theme/tokens.dart'; -import '../kit/kit.dart'; -import '../kit/charts.dart'; - -class RecoveryScreen extends StatelessWidget { - final num? readiness; - final double confidence; - final List contributors; - const RecoveryScreen({ - super.key, - required this.readiness, - required this.confidence, - required this.contributors, - }); - - @override - Widget build(BuildContext context) { - final r = readiness; - final t = r == null ? double.nan : (r / 100).clamp(0.0, 1.0).toDouble(); - final color = t.isNaN ? AppColors.inkMuted : AppColors.scoreColor(t); - return Scaffold( - backgroundColor: AppColors.bg, - body: SafeArea( - bottom: false, - child: ListView( - padding: const EdgeInsets.symmetric(horizontal: Sp.screen), - children: [ - const SizedBox(height: Sp.x4), - Row(children: [ - RoundIconButton(Ic.arrowLeft, onTap: () => Navigator.maybePop(context)), - const SizedBox(width: Sp.x3), - Text('Recovery', style: AppText.h1), - ]), - const SizedBox(height: Sp.x5), - - // Hero ring. - GlowCard( - padding: const EdgeInsets.symmetric(vertical: Sp.x7, horizontal: Sp.x5), - child: Center( - child: RingStat( - t: t, color: color, size: 196, stroke: 16, - center: Column(mainAxisSize: MainAxisSize.min, children: [ - if (r == null) metricDash(44) - else Text(r.round().toString(), - style: AppText.display.copyWith(color: color)), - const SizedBox(height: Sp.x2), - Text('READINESS', style: AppText.overline), - ]), - ), - ), - ), - const SizedBox(height: Sp.x3), - Row(children: [ - const AppIcon(Ic.info, size: 14, color: AppColors.inkMuted), - const SizedBox(width: Sp.x2), - Expanded(child: Text( - 'Estimated — not HRV-based. This firmware does not expose ' - 'beat-to-beat intervals, so recovery is derived from resting HR, ' - 'sleep and consistency.', style: AppText.captionMuted)), - ]), - const SizedBox(height: Sp.x6), - - SectionHeader('What shaped it'), - if (contributors.isEmpty) - ProCard(child: Text('Not enough data yet to break this down.', - style: AppText.bodySoft)) - else - for (final c in contributors) ...[ - _contributor(c), - const SizedBox(height: Sp.x3), - ], - const SizedBox(height: 40), - ], - ), - ), - ); - } - - Widget _contributor(CoachContributor c) { - // impact is signed points; negative = cost. Magnitude → bar width (max ~50). - final cost = c.impact < -0.5; - final color = cost ? AppColors.bad : AppColors.good; - final mag = (c.impact.abs() / 50).clamp(0.0, 1.0); - return ProCard( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Expanded(child: Text(c.label, style: AppText.title)), - Container( - padding: const EdgeInsets.symmetric(horizontal: Sp.x2, vertical: 3), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(R.pill)), - child: Text( - '${c.impact >= 0 ? '+' : '−'}${c.impact.abs().toStringAsFixed(0)} pts', - style: AppText.caption.copyWith(color: color, fontWeight: FontWeight.w700), - ), - ), - ]), - const SizedBox(height: Sp.x3), - // impact bar (centered baseline: cost left/red, support right/green). - ClipRRect( - borderRadius: BorderRadius.circular(R.pill), - child: LinearProgressIndicator( - value: mag, - minHeight: 7, - backgroundColor: AppColors.surfaceAlt, - valueColor: AlwaysStoppedAnimation(color), - ), - ), - const SizedBox(height: Sp.x3), - Row(children: [ - if (c.value != null) - Text(_fmt(c.key, c.value!), style: AppText.label), - if (c.baseline != null) ...[ - const SizedBox(width: Sp.x2), - Text('vs ${_fmt(c.key, c.baseline!)} baseline', style: AppText.captionMuted), - ], - ]), - const SizedBox(height: Sp.x2), - Text(c.note, style: AppText.bodySoft), - ]), - ); - } - - String _fmt(String key, num v) { - switch (key) { - case 'rhr': - return '${v.round()} bpm'; - case 'sleep_debt': - return '${(v / 60).floor()}h ${(v % 60).round()}m'; - case 'sleep_quality': - return '${v.round()}%'; - default: - return v.toString(); - } - } -} diff --git a/lib/ui/concern/detail_cards.dart b/lib/ui/screens/detail_cards.dart similarity index 64% rename from lib/ui/concern/detail_cards.dart rename to lib/ui/screens/detail_cards.dart index f873238..7645717 100644 --- a/lib/ui/concern/detail_cards.dart +++ b/lib/ui/screens/detail_cards.dart @@ -1,7 +1,7 @@ -// Per-concern day-detail cards. Each fetches its /day/* endpoint and renders with +// Per-metric day-detail cards. Each fetches its /day/* endpoint and renders with // the EXISTING kit (RingStat, SegmentBar, DetailRow, ProCard, StatTile) — no new // widget types. Used both for the "Today" tab (date = today) and as the inline -// drill leaf (date = the tapped day). One card per concern keeps it DRY. +// drill leaf (date = the tapped day). One card per metric keeps it DRY. import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -57,83 +57,9 @@ class _FetchState extends State<_Fetch> { } } -// Zone palette reused across concerns. +// Zone palette reused across metrics. const _zoneColors = [AppColors.cool, AppColors.loadDetraining, AppColors.good, AppColors.warn, AppColors.coral]; -// ── SLEEP ──────────────────────────────────────────────────────────────────── -class SleepDayCard extends StatelessWidget { - final String date; - const SleepDayCard({super.key, required this.date}); - @override - Widget build(BuildContext context) { - return _Fetch( - load: (api) => api.getDaySleep(date), - build: (d) { - if (d['has_sleep'] != true) { - return ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x5), - child: Center(child: Text('No sleep recorded for this night', style: AppText.captionMuted)))); - } - final dur = (d['duration_min'] as num?)?.toDouble() ?? 0; - final need = (d['need_min'] as num?)?.toDouble() ?? 480; - final eff = (d['efficiency'] as num?)?.toDouble(); - final st = (d['stages'] as Map?) ?? const {}; - final light = (st['light_min'] as num?)?.toDouble() ?? 0; - final deep = (st['deep_min'] as num?)?.toDouble() ?? 0; - final rem = (st['rem_min'] as num?)?.toDouble() ?? 0; - final reg = d['regularity']; - final debt = d['debt_min']; - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Hero: ring + "Xh Ym of N.Nh need" — the layout you liked. - ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x4), child: Row(children: [ - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('TIME ASLEEP', style: AppText.overline), - const SizedBox(height: Sp.x2), - Text(hm(dur), style: AppText.metric), - const SizedBox(height: 2), - Text('of ${(need / 60).toStringAsFixed(1)}h need', style: AppText.captionMuted), - ])), - RingStat( - t: need > 0 ? (dur / need).clamp(0.0, 1.0) : 0, - color: AppColors.loadDetraining, size: 96, stroke: 10, - center: Text('${need > 0 ? ((dur / need) * 100).round() : 0}%', - style: AppText.metricSm.copyWith(color: AppColors.loadDetraining)), - ), - ]))), - const SizedBox(height: Sp.x3), - // Stages. - ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x4), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [Text('Stages', style: AppText.label), const Spacer(), const Tag('BETA', color: AppColors.warn)]), - const SizedBox(height: Sp.x3), - SegmentBar([deep, rem, light], const [AppColors.coral, AppColors.coralSoft, AppColors.cool], height: 14), - const SizedBox(height: Sp.x3), - _stageRow('Deep', deep, AppColors.coral), - _stageRow('REM', rem, AppColors.coralSoft), - _stageRow('Light', light, AppColors.cool), - ]))), - const SizedBox(height: Sp.x3), - ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x2), child: Column(children: [ - if (eff != null) DetailRow(label: 'Efficiency', value: '${(eff * 100).round()}%'), - if (reg != null) DetailRow(label: 'Regularity (SRI)', value: '${(reg as num).round()}/100'), - if (debt != null) DetailRow(label: 'Sleep debt', value: hm(debt as num)), - ]))), - ]); - }, - ); - } - - Widget _stageRow(String label, double min, Color c) => Padding( - padding: const EdgeInsets.only(bottom: Sp.x2), - child: Row(children: [ - Container(width: 10, height: 10, decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(3))), - const SizedBox(width: Sp.x2), - Text(label, style: AppText.body), - const Spacer(), - Text(hm(min), style: AppText.label), - ]), - ); -} - // ── HEART ──────────────────────────────────────────────────────────────────── class HeartDayCard extends StatelessWidget { final String date; @@ -299,32 +225,12 @@ class HeartDayCard extends StatelessWidget { ]), ], - // Illness — the 3 signals (Mahalanobis). - if (illness != null && illness['signal'] == true) ...[ + // Illness watch — ALWAYS shown (Mahalanobis of resting HR / HRV / temp). + // Three honest states: active signal, all-clear, or still building baseline. + ...[ const SizedBox(height: Sp.x6), - SectionHeader('Body signal'), - ProCard(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Container( - padding: const EdgeInsets.all(9), - decoration: BoxDecoration(color: AppColors.warnSoft, borderRadius: BorderRadius.circular(R.chip)), - child: const AppIcon(Ic.info, size: 17, color: AppColors.warn), - ), - const SizedBox(width: Sp.x3), - Expanded(child: Text(illness['note']?.toString() ?? 'Elevated body signal', - style: AppText.bodySoft)), - ]), - if ((illness['drivers'] as List?)?.isNotEmpty ?? false) ...[ - const SizedBox(height: Sp.x4), - for (final dr in (illness['drivers'] as List).whereType()) - Padding(padding: const EdgeInsets.only(top: Sp.x2), child: Row(children: [ - const AppIcon(Ic.up, size: 15, color: AppColors.warn), - const SizedBox(width: Sp.x2), - Expanded(child: Text(dr['label']?.toString() ?? '', style: AppText.body)), - Text(dr['detail']?.toString() ?? '', style: AppText.captionMuted), - ])), - ], - ])), + SectionHeader('Illness watch'), + _IllnessCard(illness), ], // What affected this — display-only (no navigation loop), properly padded. @@ -342,9 +248,82 @@ class HeartDayCard extends StatelessWidget { } } -// ── CONCERN EXTRAS: personal records + journal patterns ───────────────────── +// Illness watch — always visible. Renders one of three honest states from the +// Mahalanobis illness object: a fired signal (amber), all-clear (green), or +// "still building baseline" (muted) when there aren't yet ~7 nights to compare. +class _IllnessCard extends StatelessWidget { + final Map? illness; + const _IllnessCard(this.illness); + + num? _num(Object? v) => v is num ? v : null; + + @override + Widget build(BuildContext context) { + final dist = _num(illness?['distance']); + final signal = illness?['signal'] == true; + final drivers = (illness?['drivers'] as List?)?.whereType().toList() ?? const []; + + // No baseline yet → honest "building" state. + if (illness == null || dist == null) { + return ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x4), child: Row(children: [ + Container( + padding: const EdgeInsets.all(9), + decoration: BoxDecoration(color: AppColors.surfaceSunk, borderRadius: BorderRadius.circular(R.chip)), + child: const AppIcon(Ic.info, size: 17, color: AppColors.inkMuted), + ), + const SizedBox(width: Sp.x3), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Building your baseline', style: AppText.label), + const SizedBox(height: 2), + Text('Illness watch compares today’s resting HR, HRV and skin temperature ' + 'against your normal range. It needs about 7 nights of wear to start.', + style: AppText.captionMuted), + ])), + ]))); + } + + final accent = signal ? AppColors.warn : AppColors.good; + final softBg = signal ? AppColors.warnSoft : AppColors.goodSoft; + final title = signal ? 'Elevated body signal' : 'All clear'; + final blurb = signal + ? 'Your resting HR, HRV and temperature are deviating together — a pattern that can precede illness. A signal, not a diagnosis.' + : 'Your resting HR, HRV and temperature are within your normal range.'; + + return ProCard(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding(padding: const EdgeInsets.all(Sp.x4), child: Row(children: [ + Container( + padding: const EdgeInsets.all(9), + decoration: BoxDecoration(color: softBg, borderRadius: BorderRadius.circular(R.chip)), + child: AppIcon(signal ? Ic.info : Ic.check, size: 17, color: accent), + ), + const SizedBox(width: Sp.x3), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Text(title, style: AppText.label.copyWith(color: accent)), + const Spacer(), + Text('index ${dist.toStringAsFixed(1)}', style: AppText.captionMuted), + ]), + const SizedBox(height: 2), + Text(blurb, style: AppText.captionMuted), + ])), + ])), + // Per-feature deviations (what's moving), when present. + if (drivers.isNotEmpty) ...[ + const Divider(height: 1, color: AppColors.divider), + Padding(padding: const EdgeInsets.symmetric(horizontal: Sp.x4, vertical: Sp.x2), child: Column( + children: [ + for (final dr in drivers) + DetailRow(label: dr['label']?.toString() ?? '', value: dr['detail']?.toString() ?? ''), + ], + )), + ], + ])); + } +} + +// ── SECTION EXTRAS: personal records + journal patterns ───────────────────── // Resurfaces the Records (personal bests) and the journal correlation engine, -// scoped to a concern, shown on its Today tab. Honest descriptive stats only. +// scoped to a section, shown on its Today tab. Honest descriptive stats only. const _recordCfg = { 'sleep': [ ('longest_sleep', 'Longest sleep', 'dur'), @@ -361,18 +340,18 @@ const _recordCfg = { }; const _journalCols = { 'sleep': ['efficiency', 'duration_min'], - 'heart': ['resting_hr', 'readiness'], + 'heart': ['resting_hr', 'recovery'], 'body': ['strain'], }; -class ConcernExtras extends StatefulWidget { - final String concern; // 'sleep' | 'heart' | 'body' - const ConcernExtras({super.key, required this.concern}); +class SectionExtras extends StatefulWidget { + final String section; // 'sleep' | 'heart' | 'body' + const SectionExtras({super.key, required this.section}); @override - State createState() => _ConcernExtrasState(); + State createState() => _SectionExtrasState(); } -class _ConcernExtrasState extends State { +class _SectionExtrasState extends State { Map? _records; Map? _insights; bool _loading = true; @@ -409,7 +388,7 @@ class _ConcernExtrasState extends State { @override Widget build(BuildContext context) { if (_loading) return const SizedBox.shrink(); - final cfg = _recordCfg[widget.concern] ?? const []; + final cfg = _recordCfg[widget.section] ?? const []; final recs = (_records?['records'] as Map?) ?? const {}; final tiles = []; for (final c in cfg) { @@ -426,8 +405,8 @@ class _ConcernExtrasState extends State { ))); } - // Journal patterns relevant to this concern. - final cols = _journalCols[widget.concern] ?? const []; + // Journal patterns relevant to this section. + final cols = _journalCols[widget.section] ?? const []; final patternRows = []; for (final ins in ((_insights?['insights'] as List?) ?? const [])) { final tag = (ins as Map)['tag']?.toString() ?? ''; @@ -472,46 +451,3 @@ class _ConcernExtrasState extends State { } } -// ── LUNGS ──────────────────────────────────────────────────────────────────── -class LungsDayCard extends StatelessWidget { - final String date; - const LungsDayCard({super.key, required this.date}); - @override - Widget build(BuildContext context) { - return _Fetch( - load: (api) => api.getDayLungs(date), - build: (d) { - final resp = (d['resp'] as Map?) ?? (d['resp_rate'] as Map?); - final spo2 = (d['spo2'] as Map?); - if (resp == null && spo2 == null) { - return ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x5), - child: Center(child: Text('No respiratory data yet\n(needs a clean night of resting RR)', - textAlign: TextAlign.center, style: AppText.captionMuted)))); - } - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (resp != null) - ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x4), child: Row(children: [ - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('RESPIRATORY RATE', style: AppText.overline), - const SizedBox(height: Sp.x2), - Row(crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ - Text('${resp['value']}', style: AppText.metric), - const SizedBox(width: 4), - Text('brpm', style: AppText.caption), - ]), - const SizedBox(height: 2), - const Text('from RR (RSA)', style: TextStyle(fontSize: 11, color: AppColors.inkMuted)), - ])), - const Tag('EST.', color: AppColors.inkMuted), - ]))), - if (spo2 != null) ...[ - const SizedBox(height: Sp.x3), - ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x2), child: Column(children: [ - DetailRow(label: 'Blood-oxygen vs baseline', value: '${spo2['value']} Δ'), - ]))), - ], - ]); - }, - ); - } -} diff --git a/lib/ui/concern/metric_row.dart b/lib/ui/screens/metric_row.dart similarity index 98% rename from lib/ui/concern/metric_row.dart rename to lib/ui/screens/metric_row.dart index 49d6dc3..6d7deac 100644 --- a/lib/ui/concern/metric_row.dart +++ b/lib/ui/screens/metric_row.dart @@ -1,5 +1,5 @@ -// MetricRow + metric dictionary — the polished, breathing building block for every -// concern detail. Icon chip + label + a one-line "what this is" + value, with real +// MetricRow + metric dictionary — the polished, breathing building block for every metric +// detail. Icon chip + label + a one-line "what this is" + value, with real // padding. Group several in a MetricGroup (one ProCard, hairline dividers). This is // what makes the new screens read like the hand-written ones instead of flat lists. diff --git a/lib/ui/concern/concern_screen.dart b/lib/ui/screens/metric_screen.dart similarity index 95% rename from lib/ui/concern/concern_screen.dart rename to lib/ui/screens/metric_screen.dart index 2495a58..910ce79 100644 --- a/lib/ui/concern/concern_screen.dart +++ b/lib/ui/screens/metric_screen.dart @@ -1,9 +1,9 @@ -// ConcernScreen — the ONE reusable screen every concern (Sleep/Heart/Body/…) plugs +// MetricScreen — the ONE reusable screen every metric (Sleep/Heart/Body/…) plugs // into. Title + right-aligned scale toggle (Today·Week·Month·3M), exactly like the // hand-written Stats screen. The over-time view is a GlowCard HERO (overline → big // display number + delta → subtitle → tappable bars inside it), then inline drill: // tap a month bar → its weeks expand below → tap a week → its 7 days → tap a day → -// the concern's rich detail. All from the existing kit; numbers on every bar. +// the metric's rich detail. All from the existing kit; numbers on every bar. import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -20,7 +20,7 @@ const _tabs = ['Today', 'Week', 'Month', '3M']; const _wd = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; const _mon = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; -class ConcernScreen extends StatefulWidget { +class MetricScreen extends StatefulWidget { final String title; final String metric; // /trend key for the bars final IconData icon; @@ -28,7 +28,7 @@ class ConcernScreen extends StatefulWidget { final String Function(double v)? valueFmt; final DetailBuilder todayDetail; final DayDetailBuilder dayDetail; - const ConcernScreen({ + const MetricScreen({ super.key, required this.title, required this.metric, @@ -40,17 +40,17 @@ class ConcernScreen extends StatefulWidget { }); @override - State createState() => _ConcernScreenState(); + State createState() => _MetricScreenState(); } -class _ConcernScreenState extends State { +class _MetricScreenState extends State { int _tab = 0; @override Widget build(BuildContext context) { final scale = _tab == 1 ? 'week' : _tab == 2 ? 'month' : 'quarter'; return Scaffold( - // Opaque bg so a PUSHED concern screen (from a gauge / driver) isn't a black + // Opaque bg so a PUSHED metric screen (from a gauge / driver) isn't a black // backdrop; as a tab it matches the shell background anyway. backgroundColor: AppColors.bg, body: SafeArea( diff --git a/lib/ui/concern/concern_screens.dart b/lib/ui/screens/screens.dart similarity index 69% rename from lib/ui/concern/concern_screens.dart rename to lib/ui/screens/screens.dart index a59d81e..9e14084 100644 --- a/lib/ui/concern/concern_screens.dart +++ b/lib/ui/screens/screens.dart @@ -1,22 +1,22 @@ -// The concern screens — thin wrappers that configure the reusable ConcernScreen -// with each concern's trend metric + detail card. Reached from the navbar and from -// Today's per-concern cards (one canonical screen, reached from everywhere). +// The metric screens — thin wrappers that configure the reusable MetricScreen +// with each metric's trend series + detail card. Reached from the navbar and from +// Today's per-metric cards (one canonical screen, reached from everywhere). import 'package:flutter/material.dart'; import '../../theme/tokens.dart'; import '../kit/kit.dart'; import '../sleep/sleep_detail_screen.dart'; import '../activity/strain_detail_screen.dart'; -import 'concern_screen.dart'; +import 'metric_screen.dart'; import 'detail_cards.dart'; String todayUtc() => DateTime.now().toUtc().toIso8601String().substring(0, 10); -class SleepConcernScreen extends StatelessWidget { - const SleepConcernScreen({super.key}); +class SleepScreen extends StatelessWidget { + const SleepScreen({super.key}); @override - Widget build(BuildContext context) => ConcernScreen( + Widget build(BuildContext context) => MetricScreen( title: 'Sleep', metric: 'sleep', icon: Ic.moon, @@ -26,23 +26,23 @@ class SleepConcernScreen extends StatelessWidget { // plus sleep records + journal patterns on Today. todayDetail: (ctx) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ SleepDetailScreen(date: todayUtc(), embedded: true), - const ConcernExtras(concern: 'sleep'), + const SectionExtras(section: 'sleep'), ]), dayDetail: (ctx, date) => SleepDetailScreen(date: date, embedded: true), ); } -class HeartConcernScreen extends StatelessWidget { - const HeartConcernScreen({super.key}); +class HeartScreen extends StatelessWidget { + const HeartScreen({super.key}); @override - Widget build(BuildContext context) => ConcernScreen( + Widget build(BuildContext context) => MetricScreen( title: 'Heart', metric: 'resting_hr', // stable daily series for the bars icon: Ic.heart, accent: AppColors.coral, todayDetail: (ctx) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ HeartDayCard(date: todayUtc()), - const ConcernExtras(concern: 'heart'), + const SectionExtras(section: 'heart'), ]), dayDetail: (ctx, date) => HeartDayCard(date: date), ); @@ -51,17 +51,17 @@ class HeartConcernScreen extends StatelessWidget { /// Body — strain / training load / calories / steps / activity. Bars track daily /// strain; the detail is the rich Strain screen (embedded), reused over time. /// (Respiratory rate + SpO₂ moved to Sleep + Heart; Lungs no longer a tab.) -class BodyConcernScreen extends StatelessWidget { - const BodyConcernScreen({super.key}); +class BodyScreen extends StatelessWidget { + const BodyScreen({super.key}); @override - Widget build(BuildContext context) => ConcernScreen( + Widget build(BuildContext context) => MetricScreen( title: 'Body', metric: 'strain', icon: Ic.strain, accent: AppColors.coral, todayDetail: (ctx) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ StrainDetailScreen(date: todayUtc(), embedded: true), - const ConcernExtras(concern: 'body'), + const SectionExtras(section: 'body'), ]), dayDetail: (ctx, date) => StrainDetailScreen(date: date, embedded: true), ); diff --git a/lib/ui/sleep/sleep_detail_screen.dart b/lib/ui/sleep/sleep_detail_screen.dart index b2c7a68..83f3aa8 100644 --- a/lib/ui/sleep/sleep_detail_screen.dart +++ b/lib/ui/sleep/sleep_detail_screen.dart @@ -14,7 +14,7 @@ import '../kit/charts.dart'; class SleepDetailScreen extends StatefulWidget { final String date; // 'YYYY-MM-DD' // When true, render just the section content (no Scaffold/back bar) so it can be - // embedded inside the Sleep ConcernScreen (Today/Week/Month/3M). This is the EXACT + // embedded inside the Sleep screen (Today/Week/Month/3M). This is the EXACT // same loved layout — only the chrome differs. final bool embedded; const SleepDetailScreen({super.key, required this.date, this.embedded = false}); @@ -231,7 +231,7 @@ class _SleepDetailScreenState extends State { // ── build ────────────────────────────────────────────────────────────────── /// The night's sections (phase-aware). Shared by the standalone screen and the - /// embedded (ConcernScreen) mode so the layout is identical. + /// embedded mode so the layout is identical. List _sections() { if (_phase == _Phase.loading) return [_loading()]; if (_phase == _Phase.empty) { @@ -270,7 +270,7 @@ class _SleepDetailScreenState extends State { @override Widget build(BuildContext context) { - // Embedded in the Sleep ConcernScreen: just the sections (its ListView scrolls). + // Embedded in the Sleep screen: just the sections (its ListView scrolls). if (widget.embedded) { return Column(crossAxisAlignment: CrossAxisAlignment.start, children: _sections()); } diff --git a/lib/ui/sleep/sleep_screen.dart b/lib/ui/sleep/sleep_screen.dart deleted file mode 100644 index 21e7505..0000000 --- a/lib/ui/sleep/sleep_screen.dart +++ /dev/null @@ -1,451 +0,0 @@ -// Sleep tab — last night's duration vs need, stages, efficiency, regularity, -// and recent nights. - -import 'package:flutter/material.dart'; - -import '../../models/metric.dart'; -import '../../models/payloads.dart'; -import '../../net/api_client.dart'; -import '../../theme/theme.dart'; -import '../../theme/tokens.dart'; -import '../kit/charts.dart'; -import '../kit/kit.dart'; -import '../widgets/screen_loader.dart'; - -class SleepScreen extends StatefulWidget { - const SleepScreen({super.key}); - @override - State createState() => _SleepScreenState(); -} - -class _SleepScreenState extends State - with ScreenLoaderMixin { - // Stage palette — coral-forward with cool accents for depth. - static const _light = AppColors.loadDetraining; // cool blue - static const _deep = AppColors.coralDeep; - static const _rem = AppColors.coral; - - @override - String get cacheKey => 'sleep'; - - @override - Future fetch(ApiClient api) => api.getSleep(); - - @override - bool isEmpty(Object? d) => _rows(d).isEmpty; - - List> _rows(Object? d) { - if (d is List) { - return d.whereType().map((e) => e.cast()).toList(); - } - return const []; - } - - // ── formatters ────────────────────────────────────────────────────────── - String _dur(Metric m) { - if (m.isEmpty) return '—'; - final mins = m.value!.toInt(); - return '${mins ~/ 60}h ${mins % 60}m'; - } - - String _durMins(int mins) => '${mins ~/ 60}h ${mins % 60}m'; - - String _clock(int? epoch) { - if (epoch == null) return '—'; - final dt = DateTime.fromMillisecondsSinceEpoch(epoch * 1000).toLocal(); - final h = dt.hour % 12 == 0 ? 12 : dt.hour % 12; - final m = dt.minute.toString().padLeft(2, '0'); - return '$h:$m ${dt.hour < 12 ? 'AM' : 'PM'}'; - } - - String _effPct(Metric m) { - if (m.isEmpty) return '—'; - final v = m.value!.toDouble(); - return '${(v <= 1 ? v * 100 : v).round()}'; - } - - @override - Widget build(BuildContext context) { - final rows = _rows(data); - final night = SleepData.fromRows(rows); - - return SafeArea( - bottom: false, - child: RefreshIndicator( - onRefresh: () => refresh(), - color: AppColors.coral, - child: ListView( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.symmetric(horizontal: Sp.screen), - children: [ - const SizedBox(height: Sp.x4), - _TopTitle(title: 'Sleep', freshness: freshnessLabel), - const SizedBox(height: Sp.x4), - if (phase == LoadPhase.loading) - ..._skeleton() - else if (phase == LoadPhase.error && data == null) - _ErrorCard(message: errorText ?? 'Pull to retry.') - else if (phase == LoadPhase.empty) - const _EmptyCard( - icon: Ic.moon, - title: 'No sleep recorded yet', - message: - 'Wear your strap overnight. After your next sync we\'ll show ' - 'duration, efficiency and estimated stages.', - ) - else - ..._content(night, rows), - const SizedBox(height: 110), - ], - ), - ), - ); - } - - List _skeleton() => const [ - _Skeleton(height: 280), - SizedBox(height: Sp.x4), - _Skeleton(height: 150), - SizedBox(height: Sp.x4), - _Skeleton(height: 120), - ]; - - List _content(SleepData n, List> rows) { - // Sleep need: fall back to 8h unless we have a PLAUSIBLE personal need - // (≥3h). Sparse early data can yield garbage like 1 min → "0.0h"; floor it. - final rawNeed = n.needMin.value?.toDouble() ?? 0; - final need = rawNeed >= 180 ? rawNeed : 480.0; - final dur = n.durationMin; - final fill = dur.isEmpty || need <= 0 - ? double.nan - : (dur.value! / need).clamp(0.0, 1.0).toDouble(); - - return [ - _heroRing(n, need, fill), - const SizedBox(height: Sp.x4), - _stagesCard(n), - const SizedBox(height: Sp.x6), - const SectionHeader('Details'), - _detailTiles(n), - const SizedBox(height: Sp.x6), - const SectionHeader('Recent nights'), - _RecentNights(rows: rows, durMins: _durMins), - ]; - } - - // HERO — ring of duration vs need, big duration in center. - Widget _heroRing(SleepData n, double need, double fill) { - final dur = n.durationMin; - return ProCard( - padding: const EdgeInsets.symmetric(vertical: Sp.x7, horizontal: Sp.x5), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const AppIcon(Ic.bed, size: 18, color: AppColors.coral), - const SizedBox(width: Sp.x2), - Text('LAST NIGHT', style: AppText.overline), - if (!dur.isEmpty) ...[ - const SizedBox(width: Sp.x2), - ConfDot(dur.confidence), - ], - ], - ), - const SizedBox(height: Sp.x5), - RingStat( - t: fill, - color: AppColors.coral, - size: 196, - stroke: 16, - center: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (dur.isEmpty) - metricDash(40) - else - Text(_dur(dur), style: AppText.display), - const SizedBox(height: Sp.x2), - Text( - need > 0 - ? 'of ${(need / 60).toStringAsFixed(1)}h need' - : 'sleep duration', - style: AppText.caption.copyWith(color: AppColors.inkMuted), - ), - ], - ), - ), - if (!n.efficiency.isEmpty) ...[ - const SizedBox(height: Sp.x5), - Text('${_effPct(n.efficiency)}% efficient', - style: AppText.label.copyWith(color: AppColors.inkSoft)), - ], - ], - ), - ); - } - - // STAGES — light/deep/rem SegmentBar + legend. Beta because estimated. - Widget _stagesCard(SleepData n) { - final segs = <({String name, int mins, Color color})>[ - if (!n.lightMin.isEmpty) - (name: 'Light', mins: n.lightMin.value!.toInt(), color: _light), - if (!n.deepMin.isEmpty) - (name: 'Deep', mins: n.deepMin.value!.toInt(), color: _deep), - if (!n.remMin.isEmpty) - (name: 'REM', mins: n.remMin.value!.toInt(), color: _rem), - ]; - - return ProCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded(child: Text('Stages', style: AppText.h2)), - const Tag('beta', color: AppColors.coral), - const SizedBox(width: Sp.x2), - Text('estimated', style: AppText.captionMuted), - ], - ), - const SizedBox(height: Sp.x4), - if (segs.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: Sp.x2), - child: Text('Stages not available for this night.', - style: AppText.captionMuted), - ) - else ...[ - SegmentBar( - [for (final s in segs) s.mins.toDouble()], - [for (final s in segs) s.color], - height: 14, - ), - const SizedBox(height: Sp.x4), - Wrap( - spacing: Sp.x5, - runSpacing: Sp.x3, - children: [ - for (final s in segs) - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 9, - height: 9, - decoration: BoxDecoration( - color: s.color, shape: BoxShape.circle), - ), - const SizedBox(width: Sp.x2), - Text(s.name, style: AppText.caption), - const SizedBox(width: Sp.x1), - Text(_durMins(s.mins), - style: AppText.caption - .copyWith(color: AppColors.inkMuted)), - ], - ), - ], - ), - ], - ], - ), - ); - } - - Widget _detailTiles(SleepData n) { - String? sri = n.regularity.isEmpty - ? null - : n.regularity.value!.round().toString(); - return Column( - children: [ - Row(children: [ - Expanded( - child: StatTile( - icon: Ic.pulse, - label: 'Efficiency', - value: n.efficiency.isEmpty ? null : _effPct(n.efficiency), - unit: '%', - accent: AppColors.coral, - confidence: - n.efficiency.isEmpty ? null : n.efficiency.confidence, - tag: Tag.forMetric(n.efficiency), - ), - ), - const SizedBox(width: Sp.x3), - Expanded( - child: StatTile( - icon: Ic.calendar, - label: 'Regularity', - value: sri, - unit: 'SRI', - accent: AppColors.coral, - confidence: - n.regularity.isEmpty ? null : n.regularity.confidence, - tag: Tag.forMetric(n.regularity), - ), - ), - ]), - const SizedBox(height: Sp.x3), - Row(children: [ - Expanded( - child: StatTile( - icon: Ic.moon, - label: 'Onset', - value: n.onsetEpoch == null ? null : _clock(n.onsetEpoch), - accent: AppColors.inkSoft, - ), - ), - const SizedBox(width: Sp.x3), - Expanded( - child: StatTile( - icon: Ic.clock, - label: 'Wake', - value: n.wakeEpoch == null ? null : _clock(n.wakeEpoch), - accent: AppColors.inkSoft, - ), - ), - ]), - ], - ); - } -} - -/// Recent-nights duration history as MiniBars + a short list. -class _RecentNights extends StatelessWidget { - final List> rows; - final String Function(int) durMins; - const _RecentNights({required this.rows, required this.durMins}); - - num? _mins(Map r) { - final m = r['duration_min']; - final v = m is Map ? m['value'] : m; - return v is num ? v : (v is String ? num.tryParse(v) : null); - } - - @override - Widget build(BuildContext context) { - // newest first → take up to 14, reverse to chronological for the bars. - final recent = rows.take(14).toList(); - final mins = recent - .map((r) => (_mins(r) ?? 0).toDouble()) - .toList() - .reversed - .toList(); - final hasData = mins.any((m) => m > 0); - - return ProCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - Expanded( - child: Text('Last ${recent.length} nights', - style: AppText.title), - ), - Text('hours asleep', style: AppText.captionMuted), - ]), - const SizedBox(height: Sp.x5), - if (!hasData) - Text('Not enough nights yet', style: AppText.captionMuted) - else - MiniBars( - [for (final m in mins) m / 60.0], - color: AppColors.coral, - height: 90, - gap: 4, - ), - ], - ), - ); - } -} - -// ── shared little widgets (kept private; reused across both screens' -// structure but defined per-file to avoid new imports) ────────────────── - -class _TopTitle extends StatelessWidget { - final String title; - final String? freshness; - const _TopTitle({required this.title, this.freshness}); - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded(child: Text(title, style: AppText.h1)), - if (freshness != null) - Row(children: [ - const AppIcon(Ic.cloud, size: 14, color: AppColors.inkMuted), - const SizedBox(width: Sp.x1), - Text(freshness!, style: AppText.captionMuted), - ]), - ], - ); - } -} - -class _EmptyCard extends StatelessWidget { - final IconData icon; - final String title; - final String message; - const _EmptyCard( - {required this.icon, required this.title, required this.message}); - @override - Widget build(BuildContext context) { - return ProCard( - padding: const EdgeInsets.all(Sp.x7), - child: Column( - children: [ - Container( - padding: const EdgeInsets.all(Sp.x4), - decoration: BoxDecoration( - color: AppColors.coralSoft, - borderRadius: BorderRadius.circular(R.chip), - ), - child: AppIcon(icon, size: 26, color: AppColors.coral), - ), - const SizedBox(height: Sp.x4), - Text(title, style: AppText.h2, textAlign: TextAlign.center), - const SizedBox(height: Sp.x2), - Text(message, - style: AppText.bodySoft, textAlign: TextAlign.center), - ], - ), - ); - } -} - -class _ErrorCard extends StatelessWidget { - final String message; - const _ErrorCard({required this.message}); - @override - Widget build(BuildContext context) { - return ProCard( - padding: const EdgeInsets.all(Sp.x7), - child: Column( - children: [ - const AppIcon(Ic.cloud, size: 28, color: AppColors.inkMuted), - const SizedBox(height: Sp.x3), - Text('Couldn\'t load sleep', - style: AppText.h2, textAlign: TextAlign.center), - const SizedBox(height: Sp.x2), - Text(message, - style: AppText.bodySoft, textAlign: TextAlign.center), - ], - ), - ); - } -} - -class _Skeleton extends StatelessWidget { - final double height; - const _Skeleton({required this.height}); - @override - Widget build(BuildContext context) => Container( - height: height, - decoration: BoxDecoration( - color: AppColors.surfaceAlt, - borderRadius: BorderRadius.circular(R.card), - ), - ); -} diff --git a/lib/ui/today/today_screen.dart b/lib/ui/today/today_screen.dart index b1eb47c..1aa71ba 100644 --- a/lib/ui/today/today_screen.dart +++ b/lib/ui/today/today_screen.dart @@ -16,7 +16,7 @@ import '../journal/journal_screen.dart'; import '../recap/recap_screen.dart'; import '../coach/coach_screen.dart'; import '../profile/profile_screen.dart'; -import '../concern/concern_screens.dart'; +import '../screens/screens.dart'; import '../journey/journey_screen.dart'; import '../stress/stress_screen.dart'; import '../records/records_screen.dart'; @@ -442,13 +442,13 @@ class _TodayScreenState extends State children: [ _gauge('STRAIN', t.strain.isEmpty ? null : t.strain.value!.toStringAsFixed(1), null, strainT, AppColors.coral, - onTap: () => _push(const BodyConcernScreen())), + onTap: () => _push(const BodyScreen())), _gauge('SLEEP', t.sleepDuration.isEmpty ? null : (t.sleepDuration.value! / 60).toStringAsFixed(1), 'h', sleepT, AppColors.loadDetraining, - onTap: () => _push(const SleepConcernScreen())), + onTap: () => _push(const SleepScreen())), _gauge('HRV', hrv == null ? null : hrv.rmssd.toStringAsFixed(0), 'ms', hrvT, AppColors.good, - onTap: () => _push(const HeartConcernScreen())), + onTap: () => _push(const HeartScreen())), ], ), ); diff --git a/lib/ui/trends/trends_screen.dart b/lib/ui/trends/trends_screen.dart deleted file mode 100644 index a9d21b7..0000000 --- a/lib/ui/trends/trends_screen.dart +++ /dev/null @@ -1,496 +0,0 @@ -// Stats — metrics over a 7/30/90-day range. Backed by /history. - -import 'dart:math' as math; -import 'package:flutter/material.dart'; - -import '../../net/api_client.dart'; -import '../../theme/theme.dart'; -import '../../theme/tokens.dart'; -import '../kit/charts.dart'; -import '../kit/kit.dart'; -import '../widgets/screen_loader.dart'; - -class TrendsScreen extends StatefulWidget { - const TrendsScreen({super.key}); - @override - State createState() => _TrendsScreenState(); -} - -const _ranges = ['7d', '30d', '90d']; -const _rangeLabels = ['Week', 'Month', '3 Months']; - -class _TrendsScreenState extends State - with ScreenLoaderMixin { - String _range = '30d'; - - @override - String get cacheKey => 'history:$_range'; - - @override - Future fetch(ApiClient api) => api.getHistory(range: _range); - - @override - bool isEmpty(Object? d) => _History(d).isEmpty; - - String get _periodWord => - _range == '7d' ? 'this week' : (_range == '90d' ? 'last 90 days' : 'this month'); - - void _onRange(int i) { - final r = _ranges[i]; - if (r == _range) return; - setState(() => _range = r); - refresh(); - } - - @override - Widget build(BuildContext context) { - final h = _History(data); - final rangeIndex = _ranges.indexOf(_range).clamp(0, 2); - - return Scaffold( - backgroundColor: Colors.transparent, - body: SafeArea( - bottom: false, - child: RefreshIndicator( - color: AppColors.coral, - onRefresh: () => refresh(), - child: ListView( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.symmetric(horizontal: Sp.screen), - children: [ - const SizedBox(height: Sp.x4), - Row( - children: [ - Expanded(child: Text('Stats', style: AppText.h1)), - SegToggle( - options: _rangeLabels, - index: rangeIndex, - onChanged: _onRange, - ), - ], - ), - const SizedBox(height: Sp.x5), - if (phase == LoadPhase.loading) - ..._skeleton() - else if (phase == LoadPhase.empty) - _empty() - else if (phase == LoadPhase.error) - _error() - else - ..._content(h), - const SizedBox(height: 110), - ], - ), - ), - ), - ); - } - - // ── states ────────────────────────────────────────────────────────────── - List _skeleton() => [ - for (final hgt in [200.0, 180.0, 150.0, 120.0]) - Padding( - padding: const EdgeInsets.only(bottom: Sp.x4), - child: ProCard( - child: SizedBox( - height: hgt, - child: const Center( - child: SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - strokeWidth: 2, color: AppColors.coral), - ), - ), - ), - ), - ), - ]; - - Widget _empty() => ProCard( - padding: const EdgeInsets.all(Sp.x7), - child: Column(children: [ - const AppIcon(Ic.chart, size: 40, color: AppColors.inkMuted), - const SizedBox(height: Sp.x4), - Text('Stats build over time', style: AppText.h2), - const SizedBox(height: Sp.x2), - Text( - 'Keep wearing and syncing your band. Your strain, recovery, ' - 'sleep and heart-rate stats appear as data accumulates.', - textAlign: TextAlign.center, - style: AppText.bodySoft, - ), - ]), - ); - - Widget _error() => ProCard( - padding: const EdgeInsets.all(Sp.x7), - child: Column(children: [ - const AppIcon(Ic.cloud, size: 40, color: AppColors.inkMuted), - const SizedBox(height: Sp.x4), - Text("Couldn't load stats", style: AppText.h2), - const SizedBox(height: Sp.x2), - Text(errorText ?? 'Pull to retry.', - textAlign: TextAlign.center, style: AppText.bodySoft), - ]), - ); - - // ── content ───────────────────────────────────────────────────────────── - List _content(_History h) { - final strain = h.metric('strain'); - final readiness = h.metric('readiness'); - final rhr = h.metric('resting_hr'); - final cal = h.metric('calories'); - final sleepDur = h.metric('sleep_duration'); - final sleepEff = h.metric('sleep_efficiency'); - - final strainSeries = h.series('strain'); - final readinessSeries = h.series('readiness'); - - return [ - // HERO — average strain for the window. - GlowCard( - padding: const EdgeInsets.all(Sp.x6), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - const AppIcon(Ic.strain, size: 18, color: AppColors.coralDeep), - const SizedBox(width: Sp.x2), - Text('AVG STRAIN', style: AppText.overline), - ]), - const SizedBox(height: Sp.x4), - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text(_fmt(strain.avg, 1), style: AppText.display), - const SizedBox(width: Sp.x3), - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: DeltaChip(strain.deltaPct), - ), - ], - ), - const SizedBox(height: Sp.x2), - Text('avg strain · $_periodWord', style: AppText.bodySoft), - const SizedBox(height: Sp.x5), - DotMatrix(strainSeries, color: AppColors.coral, rows: 10), - ], - ), - ), - const SizedBox(height: Sp.x6), - - // SUMMARY GRID. - Text('Averages', style: AppText.h2), - const SizedBox(height: Sp.x3), - _grid([ - StatTile( - icon: Ic.strain, - label: 'Strain', - value: strain.has ? _fmt(strain.avg, 1) : null, - deltaPct: strain.deltaPct, - spark: _spark(strainSeries), - ), - StatTile( - icon: Ic.recovery, - label: 'Readiness', - value: readiness.has ? _fmt(readiness.avg, 0) : null, - unit: '%', - deltaPct: readiness.deltaPct, - accent: AppColors.good, - spark: _spark(readinessSeries), - ), - StatTile( - icon: Ic.heart, - label: 'Resting HR', - value: rhr.has ? _fmt(rhr.avg, 0) : null, - unit: 'bpm', - deltaPct: rhr.deltaPct, - deltaGoodIsUp: false, - accent: AppColors.coralDeep, - spark: _spark(h.series('resting_hr')), - ), - StatTile( - icon: Ic.fire, - label: 'Active cal', - value: cal.has ? _fmt(cal.total, 0) : null, - unit: 'kcal', - deltaPct: cal.deltaPct, - accent: AppColors.warn, - spark: _spark(h.series('calories')), - ), - StatTile( - icon: Ic.bed, - label: 'Sleep', - value: sleepDur.has ? _fmtDuration(sleepDur.avg) : null, - deltaPct: sleepDur.deltaPct, - accent: AppColors.loadDetraining, - spark: _spark(h.series('sleep_duration')), - ), - StatTile( - icon: Ic.moon, - label: 'Sleep eff.', - // efficiency is stored 0..1 — render as a percentage. - value: sleepEff.has ? _fmt((sleepEff.avg ?? 0) * 100, 0) : null, - unit: '%', - deltaPct: sleepEff.deltaPct, - accent: AppColors.good, - spark: _spark(h.series('sleep_efficiency')), - ), - ]), - const SizedBox(height: Sp.x6), - - // HR ZONES. - _zonesCard(h), - const SizedBox(height: Sp.x4), - - // COVERAGE. - _coverageCard(h), - const SizedBox(height: Sp.x6), - - // READINESS TREND. - Text('Readiness trend', style: AppText.h2), - const SizedBox(height: Sp.x3), - ProCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - const AppIcon(Ic.recovery, size: 18, color: AppColors.good), - const SizedBox(width: Sp.x2), - Text('Recovery over $_periodWord', style: AppText.label), - const Spacer(), - DeltaChip(readiness.deltaPct), - ]), - const SizedBox(height: Sp.x4), - AreaSpark(readinessSeries, color: AppColors.good, height: 110), - ], - ), - ), - ]; - } - - Widget _grid(List tiles) { - final rows = >[]; - for (var i = 0; i < tiles.length; i += 2) { - rows.add(tiles.sublist(i, math.min(i + 2, tiles.length))); - } - - return Column( - children: [ - for (final row in rows) ...[ - IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (int j = 0; j < row.length; j++) ...[ - Expanded(child: row[j]), - if (j < row.length - 1) const SizedBox(width: Sp.x3), - ], - if (row.length < 2) ...[ - const SizedBox(width: Sp.x3), - const Expanded(child: SizedBox()), - ], - ], - ), - ), - if (row != rows.last) const SizedBox(height: Sp.x3), - ], - ], - ); - } - - Widget _zonesCard(_History h) { - final z = h.zones; // [z1..z5] - final total = z.fold(0, (s, v) => s + v); - const colors = [ - AppColors.loadDetraining, - AppColors.good, - AppColors.coral, - AppColors.coralDeep, - AppColors.bad, - ]; - const names = ['Z1', 'Z2', 'Z3', 'Z4', 'Z5']; - return ProCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - const AppIcon(Ic.pulse, size: 18, color: AppColors.coral), - const SizedBox(width: Sp.x2), - Text('HR zones · $_periodWord', style: AppText.label), - const Spacer(), - Text('${total.round()} min', style: AppText.label), - ]), - const SizedBox(height: Sp.x4), - SegmentBar(z, colors, height: 14), - const SizedBox(height: Sp.x4), - Wrap( - spacing: Sp.x4, - runSpacing: Sp.x2, - children: [ - for (int i = 0; i < 5; i++) - Row(mainAxisSize: MainAxisSize.min, children: [ - Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: colors[i], shape: BoxShape.circle), - ), - const SizedBox(width: Sp.x2), - Text('${names[i]} · ${z[i].round()}m', - style: AppText.caption), - ]), - ], - ), - ], - ), - ); - } - - Widget _coverageCard(_History h) { - final worn = h.wornDays; - final total = h.totalDays; - final t = total <= 0 ? 0.0 : (worn / total).clamp(0.0, 1.0); - return ProCard( - padding: const EdgeInsets.all(Sp.x4), - child: Row(children: [ - const AppIcon(Ic.watch, size: 20, color: AppColors.inkSoft), - const SizedBox(width: Sp.x3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Wear coverage', style: AppText.label), - const SizedBox(height: Sp.x1), - ClipRRect( - borderRadius: BorderRadius.circular(R.pill), - child: LinearProgressIndicator( - value: t, - minHeight: 8, - backgroundColor: AppColors.surfaceAlt, - color: AppColors.coral, - ), - ), - ], - ), - ), - const SizedBox(width: Sp.x3), - Text('$worn / $total days', style: AppText.metricSm), - ]), - ); - } - - // ── formatting helpers ──────────────────────────────────────────────────── - static String _fmt(num? v, int dp) => - v == null ? '—' : v.toDouble().toStringAsFixed(dp); - - static String _fmtDuration(num? minutes) { - if (minutes == null) return '—'; - final m = minutes.round(); - final h = m ~/ 60; - final rem = m % 60; - return '${h}h ${rem}m'; - } - - static List? _spark(List series) { - if (series.isEmpty) return null; - // Cap the number of bars so MiniBars stays legible in a tile. - if (series.length <= 14) return series; - final step = series.length / 14; - return [for (int i = 0; i < 14; i++) series[(i * step).floor()]]; - } -} - -// ── defensive parsing of the /history payload ─────────────────────────────── - -class _Summary { - final num? avg, min, max, latest, total, deltaPct; - final String trend; - const _Summary( - {this.avg, - this.min, - this.max, - this.latest, - this.total, - this.deltaPct, - this.trend = 'flat'}); - - bool get has => avg != null || total != null || latest != null; -} - -class _History { - final Map _root; - - _History(Object? raw) - : _root = (raw is Map) ? raw.cast() : const {}; - - Map get _metrics { - final m = _root['metrics']; - return m is Map ? m.cast() : const {}; - } - - Map get _series { - final s = _root['series']; - return s is Map ? s.cast() : const {}; - } - - _Summary metric(String key) { - final raw = _metrics[key]; - if (raw is! Map) return const _Summary(); - final m = raw.cast(); - return _Summary( - avg: _num(m['avg']), - min: _num(m['min']), - max: _num(m['max']), - latest: _num(m['latest']), - total: _num(m['total']), - deltaPct: _num(m['delta_pct']), - trend: m['trend']?.toString() ?? 'flat', - ); - } - - /// A metric's daily series as plain `v` values (skips nulls). - List series(String key) { - final raw = _series[key]; - if (raw is! List) return const []; - final out = []; - for (final e in raw) { - if (e is Map) { - final v = _num(e['v']); - if (v != null) out.add(v.toDouble()); - } - } - return out; - } - - /// HR zone minutes as [z1..z5]. - List get zones { - final z = _root['hr_zones']; - if (z is! Map) return const [0, 0, 0, 0, 0]; - final m = z.cast(); - return [ - for (final k in ['z1', 'z2', 'z3', 'z4', 'z5']) - (_num(m[k]) ?? 0).toDouble() - ]; - } - - int get wornDays => (_num(_root['worn_days']) ?? 0).round(); - int get totalDays { - final t = _num(_root['total_days']) ?? _num(_root['days']); - return (t ?? 0).round(); - } - - bool get isEmpty { - final anyMetric = _metrics.values.any((v) => v is Map && (v).isNotEmpty); - final anySeries = - _series.values.any((v) => v is List && v.isNotEmpty); - return !anyMetric && !anySeries; - } - - static num? _num(Object? v) { - if (v is num) return v; - if (v is String) return num.tryParse(v); - return null; - } -} diff --git a/lib/ui/workouts/workouts_screen.dart b/lib/ui/workouts/workouts_screen.dart index 2531b1b..b9c93af 100644 --- a/lib/ui/workouts/workouts_screen.dart +++ b/lib/ui/workouts/workouts_screen.dart @@ -11,7 +11,7 @@ import '../../theme/theme.dart'; import '../../theme/tokens.dart'; import '../kit/kit.dart'; import '../kit/charts.dart'; -import '../concern/detail_cards.dart' show hm; +import '../screens/detail_cards.dart' show hm; const _exercises = [ ('run', 'Run', Ic.run), ('cycle', 'Cycle', Ic.activity), ('strength', 'Strength', Ic.fire), From a1dd15ea2f34674ad228316eac8bdf9cf90d9009 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 14 Jun 2026 14:11:39 +0530 Subject: [PATCH 07/55] =?UTF-8?q?edge:=20Wear=20time=20screen=20=E2=80=94?= =?UTF-8?q?=20tap=20home=20tile=20=E2=86=92=20coverage=20ring,=2024h=20str?= =?UTF-8?q?ip,=20on/off=20detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WearScreen (MetricScreen, metric 'wear', minutes→hours) + WearDayCard (/day/wear) - home 'Wear time' tile now navigates to it - MetricScreen header: title on its own row, time-scale filter below (long titles like 'Wear time' no longer shrink) --- lib/net/api_client.dart | 5 ++ lib/ui/screens/detail_cards.dart | 97 +++++++++++++++++++++++++++++++ lib/ui/screens/metric_screen.dart | 14 +++-- lib/ui/screens/screens.dart | 17 ++++++ lib/ui/today/today_screen.dart | 1 + 5 files changed, 130 insertions(+), 4 deletions(-) diff --git a/lib/net/api_client.dart b/lib/net/api_client.dart index f6c459e..806f968 100644 --- a/lib/net/api_client.dart +++ b/lib/net/api_client.dart @@ -235,6 +235,11 @@ class ApiClient { Future> getDayLungs(String date) => _getObj('/day/lungs', {'date': date}); + /// GET /day/wear?date= → worn minutes, coverage %, hourly histogram, first-on / + /// last-off, wear-stretch count + longest off-wrist gap. + Future> getDayWear(String date) => + _getObj('/day/wear', {'date': date}); + // ── workouts (manual/live/auto) ────────────────────────────────────────── /// GET /workouts?range=week|month|quarter → list + training-volume summary. Future> getWorkouts({String range = 'month'}) => diff --git a/lib/ui/screens/detail_cards.dart b/lib/ui/screens/detail_cards.dart index 7645717..06bd011 100644 --- a/lib/ui/screens/detail_cards.dart +++ b/lib/ui/screens/detail_cards.dart @@ -321,6 +321,103 @@ class _IllnessCard extends StatelessWidget { } } +// ── WEAR TIME ──────────────────────────────────────────────────────────────── +// How long the strap was on the wrist for a day: a coverage ring + worn time hero, +// a 24-hour coverage strip (minutes worn each hour), and when it went on/off. +// All from /day/wear (device wrist sensor, tier AUTH). Existing kit only. +class WearDayCard extends StatelessWidget { + final String date; + const WearDayCard({super.key, required this.date}); + + num? _n(Object? v) => v is num ? v : null; + + // unix seconds → local "h:mm a" + String _clock(num? ts) { + if (ts == null) return '—'; + final d = DateTime.fromMillisecondsSinceEpoch(ts.toInt() * 1000).toLocal(); + final h = d.hour % 12 == 0 ? 12 : d.hour % 12; + final ap = d.hour < 12 ? 'AM' : 'PM'; + return '$h:${d.minute.toString().padLeft(2, '0')} $ap'; + } + + @override + Widget build(BuildContext context) { + return _Fetch( + load: (api) => api.getDayWear(date), + build: (d) { + final worn = (_n(d['worn_min']) ?? 0).toInt(); + final cov = (_n(d['coverage_pct']) ?? 0).toInt(); + final hourly = ((d['hourly'] as List?) ?? const []) + .map((e) => (e as num).toDouble()).toList(); + final firstOn = _n(d['first_on']); + final lastOn = _n(d['last_on']); + final segments = (_n(d['segments']) ?? 0).toInt(); + final longestOff = (_n(d['longest_off_min']) ?? 0).toInt(); + + if (worn == 0) { + return ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x5), + child: Center(child: Text('The strap wasn’t worn on this day', + style: AppText.captionMuted)))); + } + + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Hero — worn time + coverage ring. + GlowCard( + padding: const EdgeInsets.all(Sp.x6), + glow: AppColors.coralDeep, + child: Row(children: [ + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Row(children: [ + const AppIcon(Ic.watch, size: 16, color: AppColors.coralDeep), + const SizedBox(width: Sp.x2), + Text('TIME WORN', style: AppText.overline), + const SizedBox(width: Sp.x2), + const Tag('AUTH', color: AppColors.good), + ]), + const SizedBox(height: Sp.x3), + Text(hm(worn), style: AppText.display), + const SizedBox(height: Sp.x2), + Text('$cov% of the day', style: AppText.bodySoft), + ])), + RingStat( + t: (cov / 100).clamp(0.0, 1.0), color: AppColors.coralDeep, size: 96, stroke: 11, + center: Text('$cov%', style: AppText.metricSm), + ), + ]), + ), + + // 24-hour coverage strip. + if (hourly.length == 24) ...[ + const SizedBox(height: Sp.x6), + SectionHeader('Hourly coverage'), + ProCard(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Minutes worn in each hour of the day.', style: AppText.captionMuted), + const SizedBox(height: Sp.x3), + MiniBars(hourly, color: AppColors.coralDeep, height: 64), + const SizedBox(height: Sp.x2), + Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + Text('12a', style: AppText.captionMuted), Text('6a', style: AppText.captionMuted), + Text('12p', style: AppText.captionMuted), Text('6p', style: AppText.captionMuted), + Text('12a', style: AppText.captionMuted), + ]), + ])), + ], + + // When + how continuous. + const SizedBox(height: Sp.x6), + SectionHeader('Details'), + ProCard(child: Column(children: [ + DetailRow(label: 'First put on', value: _clock(firstOn)), + DetailRow(label: 'Last worn', value: _clock(lastOn)), + DetailRow(label: 'Wear stretches', value: '$segments'), + DetailRow(label: 'Longest off-wrist', value: longestOff > 0 ? hm(longestOff) : 'none'), + ])), + ]); + }, + ); + } +} + // ── SECTION EXTRAS: personal records + journal patterns ───────────────────── // Resurfaces the Records (personal bests) and the journal correlation engine, // scoped to a section, shown on its Today tab. Honest descriptive stats only. diff --git a/lib/ui/screens/metric_screen.dart b/lib/ui/screens/metric_screen.dart index 910ce79..8030bb0 100644 --- a/lib/ui/screens/metric_screen.dart +++ b/lib/ui/screens/metric_screen.dart @@ -60,6 +60,8 @@ class _MetricScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: Sp.screen), children: [ const SizedBox(height: Sp.x4), + // Title stands on its own full-width row; the time-scale filter sits on + // the line below so a longer title (e.g. "Wear time") never shrinks. Row(children: [ // Back button only when this screen was pushed (not when it's a tab). if (Navigator.of(context).canPop()) ...[ @@ -67,8 +69,12 @@ class _MetricScreenState extends State { const SizedBox(width: Sp.x3), ], Expanded(child: Text(widget.title, style: AppText.h1)), - SegToggle(options: _tabs, index: _tab, onChanged: (i) => setState(() => _tab = i)), ]), + const SizedBox(height: Sp.x4), + Align( + alignment: Alignment.centerLeft, + child: SegToggle(options: _tabs, index: _tab, onChanged: (i) => setState(() => _tab = i)), + ), const SizedBox(height: Sp.x5), if (_tab == 0) widget.todayDetail(context) @@ -230,7 +236,7 @@ class _DrillLevelState extends State<_DrillLevel> { const SizedBox(height: Sp.x4), Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ Text(_fmtAvg(avg, unit), style: AppText.display), - if (unit.isNotEmpty && avg != null && widget.metric != 'sleep') ...[ + if (unit.isNotEmpty && avg != null && widget.metric != 'sleep' && widget.metric != 'wear') ...[ const SizedBox(width: Sp.x2), Padding(padding: const EdgeInsets.only(bottom: 8), child: Text(unit, style: AppText.bodySoft)), @@ -272,8 +278,8 @@ class _DrillLevelState extends State<_DrillLevel> { String _fmtAvg(Object? avg, String unit) { if (avg == null) return '—'; final v = (avg as num).toDouble(); - // Sleep avg comes in minutes → show as Hh Mm in the hero. - if (widget.metric == 'sleep') { + // Sleep + wear avgs come in minutes → show as Hh Mm in the hero. + if (widget.metric == 'sleep' || widget.metric == 'wear') { final m = v.round(); return '${m ~/ 60}h ${m % 60}m'; } diff --git a/lib/ui/screens/screens.dart b/lib/ui/screens/screens.dart index 9e14084..dde02f4 100644 --- a/lib/ui/screens/screens.dart +++ b/lib/ui/screens/screens.dart @@ -48,6 +48,23 @@ class HeartScreen extends StatelessWidget { ); } +/// Wear time — how long the strap was actually on the wrist. Bars track daily +/// worn hours over time; the detail is the per-day wear card (hourly coverage, +/// when it went on/off, longest gap). Reached from the home "Wear time" tile. +class WearScreen extends StatelessWidget { + const WearScreen({super.key}); + @override + Widget build(BuildContext context) => MetricScreen( + title: 'Wear time', + metric: 'wear', + icon: Ic.watch, + accent: AppColors.coralDeep, + valueFmt: (v) => v == 0 ? '' : (v / 60).toStringAsFixed(1), // minutes → hours on bars + todayDetail: (ctx) => WearDayCard(date: todayUtc()), + dayDetail: (ctx, date) => WearDayCard(date: date), + ); +} + /// Body — strain / training load / calories / steps / activity. Bars track daily /// strain; the detail is the rich Strain screen (embedded), reused over time. /// (Respiratory rate + SpO₂ moved to Sleep + Heart; Lungs no longer a tab.) diff --git a/lib/ui/today/today_screen.dart b/lib/ui/today/today_screen.dart index 1aa71ba..f7cfd75 100644 --- a/lib/ui/today/today_screen.dart +++ b/lib/ui/today/today_screen.dart @@ -300,6 +300,7 @@ class _TodayScreenState extends State value: _hm(t.wearTime), accent: AppColors.coralDeep, confidence: t.wearTime.isEmpty ? null : t.wearTime.confidence, + onTap: () => _push(const WearScreen()), ), ), const SizedBox(height: Sp.x3), From 744829f8b8ac2a78d66252206e395f0976f2b4f4 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 14 Jun 2026 14:33:36 +0530 Subject: [PATCH 08/55] =?UTF-8?q?edge:=20redesign=20Workouts=20=E2=80=94?= =?UTF-8?q?=20training-summary=20hero=20+=20rich=20post-workout=20breakdow?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - list: GlowCard summary (active time, count/kcal/avg-strain, zone distribution + legend), richer tiles (type icon, when, duration, avg HR, strain) - breakdown: hero (duration + strain ring + avg/max/min HR + kcal), HR chart with drift + time-to-peak, zone legend (bpm ranges + minutes + %), HR-recovery curve, output (steps/cadence/calories/coverage) - live screen notes server auto-stop; MetricScreen-style left-aligned filter --- lib/ui/workouts/workouts_screen.dart | 313 ++++++++++++++++++++++----- 1 file changed, 256 insertions(+), 57 deletions(-) diff --git a/lib/ui/workouts/workouts_screen.dart b/lib/ui/workouts/workouts_screen.dart index b9c93af..8b7cab5 100644 --- a/lib/ui/workouts/workouts_screen.dart +++ b/lib/ui/workouts/workouts_screen.dart @@ -21,6 +21,36 @@ const _exercises = [ const _ranges = ['Today', 'Week', 'Month', '3M']; const _rangeKey = ['week', 'week', 'month', 'quarter']; // Today filters week to today +// Zone palette (Z1→Z5), shared by the bar + legend. +const _zoneColors = [AppColors.cool, AppColors.loadDetraining, AppColors.good, AppColors.warn, AppColors.coral]; + +IconData _typeIcon(String? type) { + for (final e in _exercises) { if (e.$1 == type) return e.$3; } + return Ic.run; +} + +String _typeLabel(String? type) { + if (type == null || type.isEmpty) return 'Workout'; + return type[0].toUpperCase() + type.substring(1); +} + +// Relative-ish date for a session start (local). +String _whenLabel(int? startTs) { + if (startTs == null || startTs == 0) return ''; + final d = DateTime.fromMillisecondsSinceEpoch(startTs * 1000).toLocal(); + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final that = DateTime(d.year, d.month, d.day); + final diff = today.difference(that).inDays; + final h = d.hour % 12 == 0 ? 12 : d.hour % 12; + final ap = d.hour < 12 ? 'AM' : 'PM'; + final time = '$h:${d.minute.toString().padLeft(2, '0')} $ap'; + if (diff == 0) return 'Today · $time'; + if (diff == 1) return 'Yesterday · $time'; + const mon = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return '${mon[d.month - 1]} ${d.day} · $time'; +} + /// Bottom-sheet exercise picker → starts a workout → opens the live screen. Future startWorkoutFlow(BuildContext context) async { final type = await showModalBottomSheet( @@ -124,14 +154,19 @@ class _WorkoutsScreenState extends State { ], Text('Workouts', style: AppText.h1), ]), - const SizedBox(height: Sp.x3), - Center(child: SegToggle(options: _ranges, index: _range, onChanged: (i) { setState(() => _range = i); _load(); })), + const SizedBox(height: Sp.x4), + Align( + alignment: Alignment.centerLeft, + child: SegToggle(options: _ranges, index: _range, onChanged: (i) { setState(() => _range = i); _load(); }), + ), const SizedBox(height: Sp.x4), if (_loading) const Padding(padding: EdgeInsets.symmetric(vertical: Sp.x6), child: Center(child: CircularProgressIndicator())) else ...[ - if (_range != 0 && summary != null) _summary(summary), - if (_range != 0 && summary != null) const SizedBox(height: Sp.x4), + if (_range != 0 && summary != null && (summary['count'] ?? 0) > 0) ...[ + _SummaryHero(summary: summary, range: _ranges[_range], workouts: list), + const SizedBox(height: Sp.x4), + ], if (list.isEmpty) ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x6), child: Center( child: Column(children: [ @@ -142,8 +177,10 @@ class _WorkoutsScreenState extends State { Text('Tap Start, or an effort will be auto-detected.', style: AppText.captionMuted, textAlign: TextAlign.center), ]), ))) - else + else ...[ + SectionHeader(_range == 0 ? 'Today' : 'Sessions'), for (final w in list) _WorkoutTile(w as Map), + ], ], ], ), @@ -151,30 +188,69 @@ class _WorkoutsScreenState extends State { ), ); } +} + +/// Training-summary hero — total time + count/kcal/avg-strain + zone distribution. +class _SummaryHero extends StatelessWidget { + final Map summary; + final String range; + final List workouts; + const _SummaryHero({required this.summary, required this.range, required this.workouts}); + + @override + Widget build(BuildContext context) { + final count = (summary['count'] as num?)?.toInt() ?? 0; + final totalMin = summary['total_min'] as num?; + final kcal = (summary['total_calories'] as num?)?.toInt() ?? 0; + final zoneMin = ((summary['zone_min'] as List?) ?? const []) + .map((e) => (e as num).toDouble()).toList(); + // Average strain across done sessions in view. + final strains = workouts + .where((w) => (w as Map)['status'] != 'live') + .map((w) => ((w as Map)['strain'] as num?)?.toDouble() ?? 0) + .where((v) => v > 0).toList(); + final avgStrain = strains.isEmpty ? null : strains.reduce((a, b) => a + b) / strains.length; - Widget _summary(Map s) { - final byType = (s['by_type'] as Map?) ?? const {}; - final types = byType.entries.map((e) => '${(e.value as Map)['count']} ${e.key}').join(' · '); - return ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x4), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - _stat('${s['count'] ?? 0}', 'workouts'), - const SizedBox(width: Sp.x5), - _stat(hm(s['total_min'] as num?), 'active time'), - const SizedBox(width: Sp.x5), - _stat('${s['total_calories'] ?? 0}', 'kcal'), + return GlowCard( + padding: const EdgeInsets.all(Sp.x6), + glow: AppColors.coral, + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('TRAINING · ${range.toUpperCase()}', style: AppText.overline), + const SizedBox(height: Sp.x4), + Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ + Text(hm(totalMin), style: AppText.display), + const SizedBox(width: Sp.x2), + Padding(padding: const EdgeInsets.only(bottom: 8), child: Text('active', style: AppText.bodySoft)), + ]), + const SizedBox(height: Sp.x5), + Row(children: [ + _miniStat('$count', 'workouts'), + _miniStat('$kcal', 'kcal'), + _miniStat(avgStrain == null ? '—' : avgStrain.toStringAsFixed(1), 'avg strain'), + ]), + if (zoneMin.length == 5 && zoneMin.any((v) => v > 0)) ...[ + const SizedBox(height: Sp.x5), + Text('TIME IN ZONES', style: AppText.overline), + const SizedBox(height: Sp.x3), + SegmentBar(zoneMin, _zoneColors, height: 12), + const SizedBox(height: Sp.x3), + Wrap(spacing: Sp.x4, runSpacing: Sp.x2, children: [ + for (int i = 0; i < 5; i++) + Row(mainAxisSize: MainAxisSize.min, children: [ + Container(width: 9, height: 9, decoration: BoxDecoration(color: _zoneColors[i], shape: BoxShape.circle)), + const SizedBox(width: Sp.x2), + Text('Z${i + 1} · ${zoneMin[i].round()}m', style: AppText.caption), + ]), + ]), + ], ]), - if (types.isNotEmpty) ...[ - const SizedBox(height: Sp.x3), - Text(types, style: AppText.captionMuted), - ], - ]))); + ); } - Widget _stat(String v, String label) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Widget _miniStat(String v, String label) => Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(v, style: AppText.h2), Text(label, style: AppText.captionMuted), - ]); + ])); } class _WorkoutTile extends StatelessWidget { @@ -183,6 +259,7 @@ class _WorkoutTile extends StatelessWidget { @override Widget build(BuildContext context) { final live = w['status'] == 'live'; + final strain = (w['strain'] as num?); return Padding( padding: const EdgeInsets.only(bottom: Sp.x3), child: ProCard( @@ -190,20 +267,27 @@ class _WorkoutTile extends StatelessWidget { builder: (_) => WorkoutDetailScreen(id: w['id'] as String))), padding: const EdgeInsets.all(Sp.x4), child: Row(children: [ - Container(padding: const EdgeInsets.all(10), + Container(padding: const EdgeInsets.all(11), decoration: BoxDecoration(color: AppColors.coral.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(R.chip)), - child: const AppIcon(Ic.run, size: 18, color: AppColors.coral)), + child: AppIcon(_typeIcon(w['type'] as String?), size: 20, color: AppColors.coral)), const SizedBox(width: Sp.x3), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ - Text('${w['type']}'.toUpperCase(), style: AppText.label), + Text(_typeLabel(w['type'] as String?), style: AppText.label), if (w['source'] == 'auto') ...[const SizedBox(width: Sp.x2), const Tag('AUTO', color: AppColors.inkMuted)], if (live) ...[const SizedBox(width: Sp.x2), const Tag('LIVE', color: AppColors.coral)], ]), const SizedBox(height: 2), - Text('${hm(w['duration_min'] as num?)} · ${w['avg_hr'] ?? '—'} bpm · strain ${w['strain'] ?? '—'}', + Text('${_whenLabel(w['start_ts'] as int?)} · ${hm(w['duration_min'] as num?)} · ${w['avg_hr'] ?? '—'} bpm', style: AppText.captionMuted), ])), + if (!live) ...[ + Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ + Text(strain == null ? '—' : strain.toStringAsFixed(1), style: AppText.metricSm.copyWith(fontSize: 18)), + Text('strain', style: AppText.captionMuted), + ]), + const SizedBox(width: Sp.x2), + ], const AppIcon(Icons.chevron_right, size: 18, color: AppColors.inkMuted), ]), ), @@ -214,7 +298,8 @@ class _WorkoutTile extends StatelessWidget { /// Live workout — elapsed timer + recording state + Stop. The actual data records /// via the existing background BLE keepalive (foreground service / iOS restoration), /// so it keeps recording even if the app is backgrounded; the breakdown is computed -/// server-side on Stop regardless. +/// server-side on Stop regardless. If you forget to stop, the server auto-closes it +/// once your heart rate returns to baseline. class LiveWorkoutScreen extends StatefulWidget { final String workoutId; final String type; @@ -274,7 +359,8 @@ class _LiveWorkoutScreenState extends State { Text('Recording', style: AppText.body.copyWith(color: AppColors.onNightSoft)), ]), const SizedBox(height: Sp.x4), - Text('Keeps recording in the background — your data is safe even if you leave the app.', + Text('Keeps recording in the background — your data is safe even if you leave the app. ' + 'Forget to stop? It auto-ends when your heart rate settles.', textAlign: TextAlign.center, style: AppText.caption.copyWith(color: AppColors.onNightSoft)), const Spacer(), SizedBox(width: double.infinity, child: FilledButton( @@ -320,46 +406,159 @@ class _WorkoutDetailBodyState extends State<_WorkoutDetailBody> { try { final d = await api.getWorkout(widget.id); if (mounted) setState(() { _d = d; _loading = false; }); } catch (_) { if (mounted) setState(() => _loading = false); } } + + num? _n(Object? v) => v is num ? v : null; + @override Widget build(BuildContext context) { if (_loading) return const Center(child: CircularProgressIndicator()); final d = _d; if (d == null) return Center(child: Text('Not found', style: AppText.captionMuted)); + final hr = (d['hr'] as List?)?.map((e) => ((e as Map)['v'] as num?)?.toDouble() ?? 0).where((v) => v > 0).toList() ?? []; - final z = (d['zones'] as Map?); - return ListView(padding: const EdgeInsets.all(Sp.x4), children: [ - ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x4), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('${d['type']}'.toUpperCase(), style: AppText.overline), - const SizedBox(height: Sp.x2), - Text(hm(d['duration_min'] as num?), style: AppText.metric), - const SizedBox(height: Sp.x2), - Text('avg ${d['avg_hr'] ?? '—'} · max ${d['max_hr'] ?? '—'} bpm · strain ${d['strain'] ?? '—'} · ${d['calories'] ?? 0} kcal', - style: AppText.captionMuted), - ]))), + final bands = (d['zone_bands'] as List?)?.whereType().toList() ?? const []; + final curve = (d['recovery_curve'] as List?)?.whereType().toList() ?? const []; + final live = d['status'] == 'live'; + final strain = _n(d['strain']); + final drift = _n(d['hr_drift_pct']); + final ttp = _n(d['time_to_peak_min']); + + return ListView(padding: const EdgeInsets.fromLTRB(Sp.x4, Sp.x4, Sp.x4, Sp.x10), children: [ + // ── HERO ── + GlowCard( + padding: const EdgeInsets.all(Sp.x6), + glow: AppColors.coral, + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Container(padding: const EdgeInsets.all(10), + decoration: BoxDecoration(color: AppColors.coral.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(R.chip)), + child: AppIcon(_typeIcon(d['type'] as String?), size: 20, color: AppColors.coral)), + const SizedBox(width: Sp.x3), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(_typeLabel(d['type'] as String?).toUpperCase(), style: AppText.overline), + Text(_whenLabel(d['start_ts'] as int?), style: AppText.captionMuted), + ])), + if (d['source'] == 'auto') const Tag('AUTO', color: AppColors.inkMuted), + if (live) const Tag('LIVE', color: AppColors.coral), + ]), + const SizedBox(height: Sp.x5), + Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(hm(d['duration_min'] as num?), style: AppText.display), + const SizedBox(height: Sp.x1), + Text('duration', style: AppText.bodySoft), + ])), + if (strain != null) + RingStat( + t: (strain / 21).clamp(0.0, 1.0), color: AppColors.coral, size: 92, stroke: 11, + center: Column(mainAxisSize: MainAxisSize.min, children: [ + Text(strain.toStringAsFixed(1), style: AppText.metricSm), + Text('strain', style: AppText.captionMuted), + ]), + ), + ]), + const SizedBox(height: Sp.x5), + Row(children: [ + _heroStat('${d['avg_hr'] ?? '—'}', 'avg bpm'), + _heroStat('${d['max_hr'] ?? '—'}', 'max bpm'), + _heroStat('${d['min_hr'] ?? '—'}', 'min bpm'), + _heroStat('${d['calories'] ?? 0}', 'kcal'), + ]), + ]), + ), + + // ── HEART RATE ── if (hr.length > 1) ...[ - const SizedBox(height: Sp.x3), - ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x4), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const SizedBox(height: Sp.x4), + ProCard(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Heart rate', style: AppText.label), - const SizedBox(height: Sp.x2), - AreaSpark(hr, color: AppColors.coral, height: 100), - ]))), + const SizedBox(height: Sp.x3), + AreaSpark(hr, color: AppColors.coral, height: 110), + if (drift != null || ttp != null) ...[ + const SizedBox(height: Sp.x4), + const Divider(height: 1, color: AppColors.divider), + const SizedBox(height: Sp.x2), + if (ttp != null) + DetailRow(label: 'Time to peak HR', value: '${ttp.toInt()} min'), + if (drift != null) + DetailRow( + label: 'Cardiac drift', + value: '${drift > 0 ? '+' : ''}${drift.toStringAsFixed(1)}%', + trailing: AppIcon(drift > 3 ? Ic.up : Ic.down, size: 15, + color: drift > 3 ? AppColors.warn : AppColors.good), + ), + ], + ])), ], - if (z != null) ...[ - const SizedBox(height: Sp.x3), - ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x4), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('HR zones', style: AppText.label), + + // ── ZONES (bar + legend with bpm ranges + %) ── + if (bands.isNotEmpty && bands.any((b) => (b['min'] as num? ?? 0) > 0)) ...[ + const SizedBox(height: Sp.x4), + ProCard(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Time in heart-rate zones', style: AppText.label), const SizedBox(height: Sp.x3), - SegmentBar([ - (z['zone1_min'] as num?)?.toDouble() ?? 0, (z['zone2_min'] as num?)?.toDouble() ?? 0, - (z['zone3_min'] as num?)?.toDouble() ?? 0, (z['zone4_min'] as num?)?.toDouble() ?? 0, - (z['zone5_min'] as num?)?.toDouble() ?? 0, - ], const [AppColors.cool, AppColors.loadDetraining, AppColors.good, AppColors.warn, AppColors.coral], height: 14), - ]))), + SegmentBar([for (final b in bands) (b['min'] as num?)?.toDouble() ?? 0], _zoneColors, height: 16), + const SizedBox(height: Sp.x4), + for (int i = 0; i < bands.length; i++) ...[ + if (i > 0) const SizedBox(height: Sp.x3), + Row(children: [ + Container(width: 10, height: 10, decoration: BoxDecoration( + color: _zoneColors[i], borderRadius: BorderRadius.circular(3))), + const SizedBox(width: Sp.x3), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Z${bands[i]['zone']} · ${bands[i]['name']}', style: AppText.body), + Text('${bands[i]['lo']}–${bands[i]['hi']} bpm', style: AppText.captionMuted), + ])), + Text('${(bands[i]['min'] as num?)?.round() ?? 0}m', style: AppText.label), + const SizedBox(width: Sp.x3), + SizedBox(width: 38, child: Text('${bands[i]['pct'] ?? 0}%', + textAlign: TextAlign.right, style: AppText.captionMuted)), + ]), + ], + ])), ], - if (d['hrr60'] != null) ...[ - const SizedBox(height: Sp.x3), - ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x2), child: DetailRow(label: 'HR recovery (60s)', value: '${d['hrr60']} bpm'))), + + // ── RECOVERY CURVE ── + if (curve.isNotEmpty) ...[ + const SizedBox(height: Sp.x4), + ProCard(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Heart-rate recovery', style: AppText.label), + const SizedBox(height: Sp.x1), + Text('How fast your heart rate dropped after the effort — faster is fitter.', + style: AppText.captionMuted), + const SizedBox(height: Sp.x4), + Row(children: [ + for (final c in curve) + _heroStat('−${(c['drop'] as num?)?.round() ?? 0}', '${((c['sec'] as num?)?.toInt() ?? 0) ~/ 60} min'), + ]), + ])), + ] else if (d['hrr60'] != null) ...[ + const SizedBox(height: Sp.x4), + ProCard(child: DetailRow(label: 'HR recovery (60s)', value: '−${d['hrr60']} bpm')), + ], + + // ── OUTPUT ── + if (_hasOutput(d)) ...[ + const SizedBox(height: Sp.x4), + ProCard(child: Column(children: [ + if (d['steps'] != null && (d['steps'] as num) > 0) + DetailRow(label: 'Steps', value: '${d['steps']}'), + if (d['cadence_spm'] != null) + DetailRow(label: 'Cadence', value: '${d['cadence_spm']} spm'), + DetailRow(label: 'Active calories', value: '${d['calories'] ?? 0} kcal'), + if (d['coverage_pct'] != null) + DetailRow(label: 'Wrist coverage', value: '${d['coverage_pct']}%'), + ])), ], ]); } + + bool _hasOutput(Map d) => + (d['steps'] != null && (d['steps'] as num) > 0) || d['cadence_spm'] != null || + d['coverage_pct'] != null || d['calories'] != null; + + Widget _heroStat(String v, String label) => Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(v, style: AppText.metricSm.copyWith(fontSize: 18)), + Text(label, style: AppText.captionMuted), + ])); } From ff84b1988efb78f11d61b4234207ee5d7088a8cb Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 14 Jun 2026 14:36:41 +0530 Subject: [PATCH 09/55] edge: move Workouts Start to a compact blazing pill in the top-right header --- lib/ui/workouts/workouts_screen.dart | 39 +++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/lib/ui/workouts/workouts_screen.dart b/lib/ui/workouts/workouts_screen.dart index 8b7cab5..4af23a7 100644 --- a/lib/ui/workouts/workouts_screen.dart +++ b/lib/ui/workouts/workouts_screen.dart @@ -135,12 +135,6 @@ class _WorkoutsScreenState extends State { final summary = (_data?['summary'] as Map?)?.cast(); return Scaffold( backgroundColor: AppColors.bg, - floatingActionButton: FloatingActionButton.extended( - backgroundColor: AppColors.coral, - onPressed: () => startWorkoutFlow(context).then((_) => _load()), - icon: const AppIcon(Ic.run, size: 20, color: Colors.white), - label: Text('Start', style: AppText.label.copyWith(color: Colors.white)), - ), body: SafeArea( child: RefreshIndicator( onRefresh: _load, @@ -153,6 +147,8 @@ class _WorkoutsScreenState extends State { const SizedBox(width: Sp.x3), ], Text('Workouts', style: AppText.h1), + const Spacer(), + _StartButton(onTap: () => startWorkoutFlow(context).then((_) => _load())), ]), const SizedBox(height: Sp.x4), Align( @@ -190,6 +186,37 @@ class _WorkoutsScreenState extends State { } } +/// Compact "blazing" Start pill — top-right of the Workouts header. Short, coral, +/// with a warm glow so it reads as the primary action without taking a whole row. +class _StartButton extends StatelessWidget { + final VoidCallback onTap; + const _StartButton({required this.onTap}); + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: Sp.x4, vertical: Sp.x2), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppColors.coral, AppColors.coralDeep], + begin: Alignment.topLeft, end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(R.pill), + boxShadow: [ + BoxShadow(color: AppColors.coral.withValues(alpha: 0.45), blurRadius: 16, offset: const Offset(0, 4)), + ], + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + const AppIcon(Ic.fire, size: 16, color: Colors.white), + const SizedBox(width: Sp.x2), + Text('Start', style: AppText.label.copyWith(color: Colors.white)), + ]), + ), + ); + } +} + /// Training-summary hero — total time + count/kcal/avg-strain + zone distribution. class _SummaryHero extends StatelessWidget { final Map summary; From 0c879c0f0157e46a08dff9cfb701324973bf4a38 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 14 Jun 2026 14:45:29 +0530 Subject: [PATCH 10/55] =?UTF-8?q?edge:=20iOS=20widget=20shows=20three=20ri?= =?UTF-8?q?ngs=20=E2=80=94=20Strain=20=C2=B7=20Sleep=20=C2=B7=20HRV=20(rec?= =?UTF-8?q?overy=20retired)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - widget_service pushes hrv + hrv_baseline instead of readiness - TodayData.hrv exposes baseline; /today now carries it - OpenStrapWidget.swift rebuilt: TripleRings (small + medium), HRV ring scaled to your baseline + colour-coded, accessories updated to strain/HRV --- ios/OpenStrapWidget/OpenStrapWidget.swift | 153 ++++++++++++---------- lib/models/payloads.dart | 3 +- lib/widget/widget_service.dart | 6 +- 3 files changed, 93 insertions(+), 69 deletions(-) diff --git a/ios/OpenStrapWidget/OpenStrapWidget.swift b/ios/OpenStrapWidget/OpenStrapWidget.swift index 9861b86..ec9cabf 100644 --- a/ios/OpenStrapWidget/OpenStrapWidget.swift +++ b/ios/OpenStrapWidget/OpenStrapWidget.swift @@ -7,6 +7,9 @@ // /today directly (using the JWT + backend URL the app stores in the group), so // it stays current even when the app is fully closed. No @main here — the bundle // (OpenStrapWidgetBundle.swift) owns it. +// +// Shows three rings: Strain · Sleep · HRV. (Recovery was retired — the app no +// longer surfaces a recovery score; HRV is the real measured autonomic signal.) import WidgetKit import SwiftUI @@ -26,27 +29,39 @@ private extension Color { static let sleepBlue = Color(red: 124/255, green: 168/255, blue: 240/255) } -private func scoreColor(_ t: Double) -> Color { - if t >= 0.75 { return .good } - if t >= 0.5 { return .coral } - return .coralDeep -} - // MARK: - Model struct OpenStrapEntry: TimelineEntry { let date: Date let hasData: Bool - let readiness: Int // -1 = none let strain: Double // -1 = none let sleepMin: Int // -1 = none let needMin: Int + let hrv: Int // -1 = none (RMSSD, ms) + let hrvBaseline: Int // -1 = none (personal RMSSD baseline, ms) let rhr: Int // -1 = none let coachLine: String static let placeholder = OpenStrapEntry( - date: Date(), hasData: true, readiness: 78, strain: 12.4, - sleepMin: 437, needMin: 480, rhr: 54, coachLine: "Room to push today") + date: Date(), hasData: true, strain: 12.4, + sleepMin: 437, needMin: 480, hrv: 62, hrvBaseline: 58, rhr: 54, + coachLine: "Room to push today") + + // Ring fractions (0…1). + var strainT: Double { strain >= 0 ? min(strain / 21.0, 1) : 0 } + var sleepT: Double { (sleepMin >= 0 && needMin > 0) ? min(Double(sleepMin) / Double(needMin), 1) : 0 } + var hrvT: Double { + guard hrv >= 0 else { return 0 } + if hrvBaseline > 0 { return min(Double(hrv) / (1.5 * Double(hrvBaseline)), 1) } + return min(Double(hrv) / 100.0, 1) + } + // HRV reads green at/above your baseline, warmer as it drops below it. + var hrvColor: Color { + guard hrv >= 0, hrvBaseline > 0 else { return .good } + if hrv >= hrvBaseline { return .good } + if hrv >= Int(0.8 * Double(hrvBaseline)) { return .coral } + return .coralDeep + } } // MARK: - Shared store (App Group) @@ -59,10 +74,11 @@ private enum Store { return OpenStrapEntry( date: Date(), hasData: d?.bool(forKey: "has_data") ?? false, - readiness: d?.object(forKey: "readiness") as? Int ?? -1, strain: d?.object(forKey: "strain") as? Double ?? -1, sleepMin: d?.object(forKey: "sleep_min") as? Int ?? -1, needMin: (d?.object(forKey: "sleep_need_min") as? Int) ?? 480, + hrv: d?.object(forKey: "hrv") as? Int ?? -1, + hrvBaseline: d?.object(forKey: "hrv_baseline") as? Int ?? -1, rhr: d?.object(forKey: "rhr") as? Int ?? -1, coachLine: d?.string(forKey: "coach_line") ?? "") } @@ -70,10 +86,11 @@ private enum Store { static func write(_ e: OpenStrapEntry) { let d = defaults d?.set(true, forKey: "has_data") - d?.set(e.readiness, forKey: "readiness") d?.set(e.strain, forKey: "strain") d?.set(e.sleepMin, forKey: "sleep_min") d?.set(e.needMin, forKey: "sleep_need_min") + d?.set(e.hrv, forKey: "hrv") + d?.set(e.hrvBaseline, forKey: "hrv_baseline") d?.set(e.rhr, forKey: "rhr") d?.set(e.coachLine, forKey: "coach_line") d?.set(Int(Date().timeIntervalSince1970), forKey: "updated_at") @@ -117,12 +134,14 @@ private enum TodayAPI { return v.doubleValue } let daily = obj(j["daily"]); let sleep = obj(j["sleep"]); let coach = obj(j["coach"]) + let hrvObj = obj(j["hrv"]) // top-level { rmssd, baseline, ... } - let readiness = val(daily, "readiness").map { Int($0.rounded()) } ?? -1 let strain = val(daily, "strain") ?? -1 let rhr = val(daily, "resting_hr").map { Int($0.rounded()) } ?? -1 let sleepMin = val(sleep, "duration_min").map { Int($0.rounded()) } ?? -1 let needMin = val(sleep, "need_min").map { Int($0.rounded()) } ?? 480 + let hrv = (hrvObj?["rmssd"] as? NSNumber).map { Int($0.doubleValue.rounded()) } ?? -1 + let hrvBase = (hrvObj?["baseline"] as? NSNumber).map { Int($0.doubleValue.rounded()) } ?? -1 var coachLine = "" if let plan = coach?["plan"] as? [[String: Any]], let first = plan.first, @@ -131,9 +150,9 @@ private enum TodayAPI { coachLine = "Aim for strain \(Int(v.doubleValue.rounded()))" } let hasData = daily != nil || sleep != nil - return OpenStrapEntry(date: Date(), hasData: hasData, readiness: readiness, - strain: strain, sleepMin: sleepMin, needMin: needMin, - rhr: rhr, coachLine: coachLine) + return OpenStrapEntry(date: Date(), hasData: hasData, strain: strain, + sleepMin: sleepMin, needMin: needMin, hrv: hrv, + hrvBaseline: hrvBase, rhr: rhr, coachLine: coachLine) } } @@ -186,69 +205,71 @@ private func hm(_ min: Int) -> String { private func numFont(_ size: CGFloat) -> Font { .system(size: size, weight: .bold, design: .rounded) } -private struct SmallView: View { - let e: OpenStrapEntry +/// One labelled metric ring (used for all three: Strain / Sleep / HRV). +private struct MetricRing: View { + let label: String + let value: String + let t: Double + let color: Color + var size: CGFloat = 58 + var line: CGFloat = 7 + var valueSize: CGFloat = 16 var body: some View { - let t = e.readiness >= 0 ? Double(e.readiness) / 100.0 : 0 - let c = e.readiness >= 0 ? scoreColor(t) : Color.inkMuted - VStack(spacing: 8) { + VStack(spacing: 5) { ZStack { - Ring(t: t, color: c, lineWidth: 11) - Text(e.readiness >= 0 ? "\(e.readiness)" : "—").font(numFont(34)).foregroundColor(c) + Ring(t: t, color: color, lineWidth: line) + Text(value).font(numFont(valueSize)).foregroundColor(.ink).minimumScaleFactor(0.6).lineLimit(1) } - .frame(width: 92, height: 92) - Text("RECOVERY").font(.system(size: 10, weight: .semibold)).tracking(1.2) - .foregroundColor(.inkMuted) + .frame(width: size, height: size) + Text(label).font(.system(size: 9, weight: .semibold)).tracking(0.8).foregroundColor(.inkMuted) } - .padding(14) } } -private struct MetricRing: View { - let label: String; let value: String; let t: Double; let color: Color +/// The three rings in a row — the heart of the widget. +private struct TripleRings: View { + let e: OpenStrapEntry + var size: CGFloat = 58 + var line: CGFloat = 7 + var valueSize: CGFloat = 16 var body: some View { - VStack(spacing: 4) { - ZStack { - Ring(t: t, color: color, lineWidth: 6) - Text(value).font(numFont(15)).foregroundColor(.ink) + HStack(spacing: size > 56 ? 18 : 12) { + MetricRing(label: "STRAIN", + value: e.strain >= 0 ? String(format: "%.1f", e.strain) : "—", + t: e.strainT, color: .coral, size: size, line: line, valueSize: valueSize) + MetricRing(label: "SLEEP", value: hm(e.sleepMin), + t: e.sleepT, color: .sleepBlue, size: size, line: line, valueSize: valueSize - 1) + MetricRing(label: "HRV", value: e.hrv >= 0 ? "\(e.hrv)" : "—", + t: e.hrvT, color: e.hrvColor, size: size, line: line, valueSize: valueSize) + } + } +} + +private struct SmallView: View { + let e: OpenStrapEntry + var body: some View { + VStack(spacing: 10) { + TripleRings(e: e, size: 46, line: 6, valueSize: 13) + if !e.hasData { + Text("Wear + sync").font(.system(size: 10)).foregroundColor(.inkMuted) } - .frame(width: 50, height: 50) - Text(label).font(.system(size: 9, weight: .semibold)).tracking(0.8).foregroundColor(.inkMuted) } + .padding(12) } } private struct MediumView: View { let e: OpenStrapEntry var body: some View { - let rt = e.readiness >= 0 ? Double(e.readiness) / 100.0 : 0 - let rc = e.readiness >= 0 ? scoreColor(rt) : Color.inkMuted - let strainT = e.strain >= 0 ? min(e.strain / 21.0, 1) : 0 - let sleepT = (e.sleepMin >= 0 && e.needMin > 0) ? min(Double(e.sleepMin) / Double(e.needMin), 1) : 0 - HStack(spacing: 16) { - ZStack { - Ring(t: rt, color: rc, lineWidth: 12) - VStack(spacing: 0) { - Text(e.readiness >= 0 ? "\(e.readiness)" : "—").font(numFont(36)).foregroundColor(rc) - Text("RECOVERY").font(.system(size: 8, weight: .semibold)).tracking(1).foregroundColor(.inkMuted) - } - } - .frame(width: 108, height: 108) - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 14) { - MetricRing(label: "STRAIN", - value: e.strain >= 0 ? String(format: "%.1f", e.strain) : "—", - t: strainT, color: .coral) - MetricRing(label: "SLEEP", value: hm(e.sleepMin), t: sleepT, color: .sleepBlue) - } - if !e.coachLine.isEmpty { - Text(e.coachLine).font(.system(size: 12, weight: .medium)).foregroundColor(.ink).lineLimit(2) - } else if !e.hasData { - Text("Wear + sync to see today").font(.system(size: 12)).foregroundColor(.inkMuted).lineLimit(2) - } + VStack(alignment: .leading, spacing: 12) { + TripleRings(e: e, size: 64, line: 8, valueSize: 17) + if !e.coachLine.isEmpty { + Text(e.coachLine).font(.system(size: 12, weight: .medium)).foregroundColor(.ink).lineLimit(2) + } else if !e.hasData { + Text("Wear + sync to see today").font(.system(size: 12)).foregroundColor(.inkMuted).lineLimit(2) } - Spacer(minLength: 0) } + .frame(maxWidth: .infinity, alignment: .leading) .padding(16) } } @@ -257,10 +278,10 @@ private struct MediumView: View { private struct AccessoryCircularView: View { let e: OpenStrapEntry var body: some View { - Gauge(value: e.readiness >= 0 ? Double(e.readiness) / 100.0 : 0) { - Text("REC") + Gauge(value: e.strainT) { + Text("STR") } currentValueLabel: { - Text(e.readiness >= 0 ? "\(e.readiness)" : "—") + Text(e.strain >= 0 ? String(format: "%.0f", e.strain) : "—") } .gaugeStyle(.accessoryCircular) .widgetAccentable() @@ -273,7 +294,7 @@ private struct AccessoryRectangularView: View { var body: some View { VStack(alignment: .leading, spacing: 2) { Text("OpenStrap").font(.system(size: 11, weight: .bold)).widgetAccentable() - Text("Recovery \(e.readiness >= 0 ? "\(e.readiness)" : "—") Strain \(e.strain >= 0 ? String(format: "%.1f", e.strain) : "—")") + Text("Strain \(e.strain >= 0 ? String(format: "%.1f", e.strain) : "—") HRV \(e.hrv >= 0 ? "\(e.hrv)" : "—")") .font(.system(size: 13, weight: .semibold)) Text("Sleep \(hm(e.sleepMin))" + (e.rhr >= 0 ? " RHR \(e.rhr)" : "")) .font(.system(size: 12)).foregroundStyle(.secondary) @@ -311,7 +332,7 @@ struct OpenStrapWidgetEntryView: View { case .accessoryCircular: AccessoryCircularView(e: entry) case .accessoryRectangular: AccessoryRectangularView(e: entry) case .accessoryInline: - Text("Rec \(entry.readiness >= 0 ? "\(entry.readiness)" : "—") · Strain \(entry.strain >= 0 ? String(format: "%.1f", entry.strain) : "—")") + Text("Strain \(entry.strain >= 0 ? String(format: "%.1f", entry.strain) : "—") · HRV \(entry.hrv >= 0 ? "\(entry.hrv)" : "—")") default: SmallView(e: entry) } } else { @@ -329,7 +350,7 @@ struct OpenStrapWidget: Widget { OpenStrapWidgetEntryView(entry: entry) } .configurationDisplayName("OpenStrap") - .description("Your recovery, strain and sleep at a glance.") + .description("Your strain, sleep and HRV at a glance.") .supportedFamilies(supportedFamilies) } diff --git a/lib/models/payloads.dart b/lib/models/payloads.dart index e1c503c..3fced36 100644 --- a/lib/models/payloads.dart +++ b/lib/models/payloads.dart @@ -56,12 +56,13 @@ class TodayData { /// Nocturnal HRV (RMSSD, ms) — measured from beat-to-beat intervals. Null until /// a night's worth of RR has been captured. - ({double rmssd, double confidence})? get hrv { + ({double rmssd, double confidence, double? baseline})? get hrv { final h = _hrv; if (h == null || h['rmssd'] == null) return null; return ( rmssd: (h['rmssd'] as num).toDouble(), confidence: (h['confidence'] as num?)?.toDouble() ?? 0, + baseline: (h['baseline'] as num?)?.toDouble(), ); } diff --git a/lib/widget/widget_service.dart b/lib/widget/widget_service.dart index 014db96..3353f53 100644 --- a/lib/widget/widget_service.dart +++ b/lib/widget/widget_service.dart @@ -32,7 +32,7 @@ class WidgetService { static Future push(TodayData t) async { try { await init(); - final r = t.recovery; + final hrv = t.hrv; final s = t.strain; final sleep = t.sleepDuration; final need = t.sleepNeed; @@ -41,7 +41,9 @@ class WidgetService { Future setI(String k, int v) => HomeWidget.saveWidgetData(k, v); await HomeWidget.saveWidgetData('has_data', !t.isEmpty); - await setI('readiness', r.isEmpty ? -1 : r.value!.round()); + // Widget shows three rings now: Strain · Sleep · HRV (recovery retired). + await setI('hrv', hrv == null ? -1 : hrv.rmssd.round()); + await setI('hrv_baseline', hrv?.baseline == null ? -1 : hrv!.baseline!.round()); await HomeWidget.saveWidgetData( 'strain', s.isEmpty ? -1.0 : s.value!.toDouble()); await setI('sleep_min', sleep.isEmpty ? -1 : sleep.value!.round()); From 192486c7c0984c7db0d258d7b53627d90173711d Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 14 Jun 2026 14:47:54 +0530 Subject: [PATCH 11/55] edge: evenly distribute the three widget rings (equal-width columns) --- ios/OpenStrapWidget/OpenStrapWidget.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ios/OpenStrapWidget/OpenStrapWidget.swift b/ios/OpenStrapWidget/OpenStrapWidget.swift index ec9cabf..358a3d9 100644 --- a/ios/OpenStrapWidget/OpenStrapWidget.swift +++ b/ios/OpenStrapWidget/OpenStrapWidget.swift @@ -226,22 +226,27 @@ private struct MetricRing: View { } } -/// The three rings in a row — the heart of the widget. +/// The three rings in a row, each taking an equal share of the width so they're +/// evenly distributed regardless of value width. private struct TripleRings: View { let e: OpenStrapEntry var size: CGFloat = 58 var line: CGFloat = 7 var valueSize: CGFloat = 16 var body: some View { - HStack(spacing: size > 56 ? 18 : 12) { + HStack(spacing: 0) { MetricRing(label: "STRAIN", value: e.strain >= 0 ? String(format: "%.1f", e.strain) : "—", t: e.strainT, color: .coral, size: size, line: line, valueSize: valueSize) + .frame(maxWidth: .infinity) MetricRing(label: "SLEEP", value: hm(e.sleepMin), t: e.sleepT, color: .sleepBlue, size: size, line: line, valueSize: valueSize - 1) + .frame(maxWidth: .infinity) MetricRing(label: "HRV", value: e.hrv >= 0 ? "\(e.hrv)" : "—", t: e.hrvT, color: e.hrvColor, size: size, line: line, valueSize: valueSize) + .frame(maxWidth: .infinity) } + .frame(maxWidth: .infinity) } } From 47075bfb88d046d90ba29a1f517957476ed454dc Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 14 Jun 2026 15:29:23 +0530 Subject: [PATCH 12/55] =?UTF-8?q?edge:=20Trends=20&=20Fitness=20foundation?= =?UTF-8?q?=20=E2=80=94=20GenericTrendScreen=20+=20TrendMetricRow,=20Basel?= =?UTF-8?q?ineDeltaChip,=20FormChart,=20CalendarHeatmap=20(shared=20compon?= =?UTF-8?q?ents,=20built=20once)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/ui/kit/charts.dart | 88 ++++++++++++++++ lib/ui/kit/kit.dart | 32 ++++++ lib/ui/screens/trend_screen.dart | 167 +++++++++++++++++++++++++++++++ 3 files changed, 287 insertions(+) create mode 100644 lib/ui/screens/trend_screen.dart diff --git a/lib/ui/kit/charts.dart b/lib/ui/kit/charts.dart index e374002..8d80f66 100644 --- a/lib/ui/kit/charts.dart +++ b/lib/ui/kit/charts.dart @@ -479,3 +479,91 @@ class StatTile extends StatelessWidget { ); } } + +/// 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 { + final List fitness; + final List fatigue; + final double height; + const FormChart({super.key, required this.fitness, required this.fatigue, this.height = 130}); + @override + Widget build(BuildContext context) { + final fit = []; + final fat = []; + for (int i = 0; i < fitness.length; i++) { + final v = fitness[i]; + if (v != null) fit.add(FlSpot(i.toDouble(), v)); + } + for (int i = 0; i < fatigue.length; i++) { + final v = fatigue[i]; + if (v != null) fat.add(FlSpot(i.toDouble(), v)); + } + if (fit.length < 2 && fat.length < 2) { + return SizedBox(height: height, child: Center(child: Text('Not enough data yet', style: AppText.captionMuted))); + } + final all = [...fit, ...fat].map((s) => s.y); + final minY = all.reduce(math.min), maxY = all.reduce(math.max); + LineChartBarData bar(List s, Color c, {bool fill = false}) => LineChartBarData( + spots: s, isCurved: true, curveSmoothness: 0.3, color: c, barWidth: 2.5, + dotData: const FlDotData(show: false), + belowBarData: fill + ? BarAreaData(show: true, gradient: LinearGradient( + begin: Alignment.topCenter, end: Alignment.bottomCenter, + colors: [c.withValues(alpha: 0.18), Colors.transparent])) + : BarAreaData(show: false), + ); + 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: [bar(fit, AppColors.coral, fill: true), bar(fat, AppColors.loadDetraining)], + )), + ); + } +} + +/// 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 = AppColors.good, this.cell = 16}); + @override + Widget build(BuildContext context) { + 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 911a52d..2da6892 100644 --- a/lib/ui/kit/kit.dart +++ b/lib/ui/kit/kit.dart @@ -275,6 +275,38 @@ class DeltaChip extends StatelessWidget { } } +/// Baseline-delta chip — "+3 vs normal" / "−8" with an absolute value (not %), +/// colored by whether the move is good. Used on tiles + trend cards to show how +/// today compares to the user's own baseline. +class BaselineDeltaChip extends StatelessWidget { + final num? delta; // signed, in the metric's unit + final String unit; // e.g. 'bpm', 'ms', '' + final bool goodIsUp; // RHR: down is good → false + final bool showVsNormal; + const BaselineDeltaChip(this.delta, {super.key, this.unit = '', this.goodIsUp = true, this.showVsNormal = true}); + @override + Widget build(BuildContext context) { + if (delta == null) return const SizedBox.shrink(); + final v = delta!; + final up = v >= 0; + final positive = goodIsUp ? up : !up; + final c = v.abs() < 0.05 + ? AppColors.inkMuted + : (positive ? AppColors.good : AppColors.bad); + final sign = up ? '+' : '−'; + final mag = v.abs(); + final num shownMag = mag == mag.roundToDouble() ? mag.round() : (mag * 10).round() / 10; + return Container( + padding: const EdgeInsets.symmetric(horizontal: Sp.x2, vertical: 3), + decoration: BoxDecoration(color: c.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(R.pill)), + child: Text( + '$sign$shownMag${unit.isNotEmpty ? ' $unit' : ''}${showVsNormal ? ' vs normal' : ''}', + style: AppText.caption.copyWith(color: c, fontWeight: FontWeight.w700), + ), + ); + } +} + /// Tiny confidence dot (honesty system). class ConfDot extends StatelessWidget { final double confidence; diff --git a/lib/ui/screens/trend_screen.dart b/lib/ui/screens/trend_screen.dart new file mode 100644 index 0000000..89448b1 --- /dev/null +++ b/lib/ui/screens/trend_screen.dart @@ -0,0 +1,167 @@ +// GenericTrendScreen + TrendMetricRow — the ONE path every metric takes to show +// itself over time. A metric row anywhere taps into the same MetricScreen +// (Today/Week/Month/3M + drill), keyed by its /trend metric key. This is the +// anti-churn core: no metric gets its own bespoke trend screen. + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../state/app_state.dart'; +import '../../theme/theme.dart'; +import '../../theme/tokens.dart'; +import '../kit/kit.dart'; +import 'metric_screen.dart'; +import 'metric_row.dart'; + +/// Open the shared trend screen for any metric key. +void openTrend( + BuildContext context, { + required String title, + required String metric, + required IconData icon, + Color accent = AppColors.coral, + String Function(double v)? valueFmt, +}) { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => GenericTrendScreen( + title: title, metric: metric, icon: icon, accent: accent, valueFmt: valueFmt), + )); +} + +/// A reusable trend screen for a metric that doesn't need a rich per-day card — +/// the "Today" leaf is a compact current-value + explainer card; the over-time +/// tabs are the standard bars. Built entirely on MetricScreen. +class GenericTrendScreen extends StatelessWidget { + final String title; + final String metric; + final IconData icon; + final Color accent; + final String Function(double v)? valueFmt; + const GenericTrendScreen({ + super.key, + required this.title, + required this.metric, + required this.icon, + this.accent = AppColors.coral, + this.valueFmt, + }); + + @override + Widget build(BuildContext context) => MetricScreen( + title: title, + metric: metric, + icon: icon, + accent: accent, + valueFmt: valueFmt, + todayDetail: (ctx) => _TrendTodayCard(metric: metric, icon: icon, accent: accent, valueFmt: valueFmt), + dayDetail: (ctx, date) => _TrendTodayCard(metric: metric, icon: icon, accent: accent, valueFmt: valueFmt), + ); +} + +/// Compact "current value + change + what-this-is" leaf for a generic metric. +/// Reuses the same /trend data the bars use — no per-metric fetch wiring. +class _TrendTodayCard extends StatefulWidget { + final String metric; + final IconData icon; + final Color accent; + final String Function(double v)? valueFmt; + const _TrendTodayCard({required this.metric, required this.icon, required this.accent, this.valueFmt}); + @override + State<_TrendTodayCard> createState() => _TrendTodayCardState(); +} + +class _TrendTodayCardState extends State<_TrendTodayCard> { + Map? _d; + bool _loading = true; + @override + void initState() { super.initState(); _go(); } + Future _go() async { + final api = context.read().api; + if (api == null) return; + try { final d = await api.getTrend(widget.metric, scale: 'week'); if (mounted) setState(() { _d = d; _loading = false; }); } + catch (_) { if (mounted) setState(() => _loading = false); } + } + + @override + Widget build(BuildContext context) { + if (_loading) { + return const ProCard(child: Padding(padding: EdgeInsets.all(Sp.x6), + child: Center(child: SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2))))); + } + final buckets = (_d?['buckets'] as List?) ?? const []; + final unit = _d?['unit']?.toString() ?? ''; + final summary = (_d?['summary'] as Map?)?.cast(); + // Latest day with a value. + double? latest; + for (final b in buckets.reversed) { + final v = (b as Map)['value']; + if (v is num) { latest = v.toDouble(); break; } + } + final fmt = widget.valueFmt; + final shown = latest == null ? '—' : (fmt != null ? fmt(latest) : (latest == latest.roundToDouble() ? latest.toStringAsFixed(0) : latest.toStringAsFixed(1))); + final delta = summary?['delta_vs_prev']; + final info = infoFor(widget.metric); + + return GlowCard( + padding: const EdgeInsets.all(Sp.x6), + glow: widget.accent, + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + AppIcon(widget.icon, size: 16, color: widget.accent), + const SizedBox(width: Sp.x2), + Text('LATEST', style: AppText.overline), + ]), + const SizedBox(height: Sp.x3), + Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ + Text(shown, style: AppText.display), + if (unit.isNotEmpty && latest != null) ...[ + const SizedBox(width: Sp.x2), + Padding(padding: const EdgeInsets.only(bottom: 8), child: Text(unit, style: AppText.bodySoft)), + ], + if (delta is num && delta != 0) ...[ + const SizedBox(width: Sp.x3), + Padding(padding: const EdgeInsets.only(bottom: 8), child: DeltaChip(delta)), + ], + ]), + if (info != null) ...[ + const SizedBox(height: Sp.x3), + Text(info, style: AppText.bodySoft), + ], + const SizedBox(height: Sp.x2), + Text('Switch to Week · Month · 3M for the full trend.', style: AppText.captionMuted), + ]), + ); + } +} + +/// A metric line that opens its trend on tap. Thin wrapper over MetricRow so the +/// look matches every other row; the chevron signals it's drillable. +class TrendMetricRow extends StatelessWidget { + final IconData icon; + final Color accent; + final String label; + final String? info; + final String value; + final String? unit; + final Widget? valueTag; + final String metric; // /trend key + final String trendTitle; // screen title + final String Function(double v)? valueFmt; + const TrendMetricRow({ + super.key, + required this.icon, + required this.label, + required this.value, + required this.metric, + required this.trendTitle, + this.info, + this.unit, + this.accent = AppColors.coral, + this.valueTag, + this.valueFmt, + }); + @override + Widget build(BuildContext context) => MetricRow( + icon: icon, accent: accent, label: label, info: info, value: value, unit: unit, valueTag: valueTag, + onTap: () => openTrend(context, title: trendTitle, metric: metric, icon: icon, accent: accent, valueFmt: valueFmt), + ); +} From a8deb1268b92f0383638e2c82e662e2f1f6c7401 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 14 Jun 2026 15:32:50 +0530 Subject: [PATCH 13/55] =?UTF-8?q?edge:=20Today=20composite=20Readiness=20h?= =?UTF-8?q?ero=20+=20widget=20readiness=20(4=20rings:=20Ready=C2=B7Strain?= =?UTF-8?q?=C2=B7Sleep=C2=B7HRV)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TodayData.readiness/vo2max/fitness/form getters - Today: ReadinessHero above the gauges (abstains until HRV exists) - widget: push readiness; OpenStrapWidget small=2x2 grid, medium=readiness row + trio, accessories lead with Readiness --- ios/OpenStrapWidget/OpenStrapWidget.swift | 69 +++++++++++++++++------ lib/models/payloads.dart | 5 ++ lib/ui/today/today_screen.dart | 36 ++++++++++++ lib/widget/widget_service.dart | 3 +- 4 files changed, 95 insertions(+), 18 deletions(-) diff --git a/ios/OpenStrapWidget/OpenStrapWidget.swift b/ios/OpenStrapWidget/OpenStrapWidget.swift index 358a3d9..407819d 100644 --- a/ios/OpenStrapWidget/OpenStrapWidget.swift +++ b/ios/OpenStrapWidget/OpenStrapWidget.swift @@ -34,6 +34,7 @@ private extension Color { struct OpenStrapEntry: TimelineEntry { let date: Date let hasData: Bool + let readiness: Int // -1 = none (composite 0..100) — the headline let strain: Double // -1 = none let sleepMin: Int // -1 = none let needMin: Int @@ -43,11 +44,18 @@ struct OpenStrapEntry: TimelineEntry { let coachLine: String static let placeholder = OpenStrapEntry( - date: Date(), hasData: true, strain: 12.4, + date: Date(), hasData: true, readiness: 72, strain: 12.4, sleepMin: 437, needMin: 480, hrv: 62, hrvBaseline: 58, rhr: 54, coachLine: "Room to push today") // Ring fractions (0…1). + var readinessT: Double { readiness >= 0 ? Double(readiness) / 100.0 : 0 } + var readinessColor: Color { + if readiness < 0 { return .inkMuted } + if readiness >= 66 { return .good } + if readiness >= 40 { return .coral } + return .coralDeep + } var strainT: Double { strain >= 0 ? min(strain / 21.0, 1) : 0 } var sleepT: Double { (sleepMin >= 0 && needMin > 0) ? min(Double(sleepMin) / Double(needMin), 1) : 0 } var hrvT: Double { @@ -74,6 +82,7 @@ private enum Store { return OpenStrapEntry( date: Date(), hasData: d?.bool(forKey: "has_data") ?? false, + readiness: d?.object(forKey: "readiness") as? Int ?? -1, strain: d?.object(forKey: "strain") as? Double ?? -1, sleepMin: d?.object(forKey: "sleep_min") as? Int ?? -1, needMin: (d?.object(forKey: "sleep_need_min") as? Int) ?? 480, @@ -86,6 +95,7 @@ private enum Store { static func write(_ e: OpenStrapEntry) { let d = defaults d?.set(true, forKey: "has_data") + d?.set(e.readiness, forKey: "readiness") d?.set(e.strain, forKey: "strain") d?.set(e.sleepMin, forKey: "sleep_min") d?.set(e.needMin, forKey: "sleep_need_min") @@ -136,6 +146,7 @@ private enum TodayAPI { let daily = obj(j["daily"]); let sleep = obj(j["sleep"]); let coach = obj(j["coach"]) let hrvObj = obj(j["hrv"]) // top-level { rmssd, baseline, ... } + let readiness = val(daily, "readiness").map { Int($0.rounded()) } ?? -1 let strain = val(daily, "strain") ?? -1 let rhr = val(daily, "resting_hr").map { Int($0.rounded()) } ?? -1 let sleepMin = val(sleep, "duration_min").map { Int($0.rounded()) } ?? -1 @@ -150,7 +161,7 @@ private enum TodayAPI { coachLine = "Aim for strain \(Int(v.doubleValue.rounded()))" } let hasData = daily != nil || sleep != nil - return OpenStrapEntry(date: Date(), hasData: hasData, strain: strain, + return OpenStrapEntry(date: Date(), hasData: hasData, readiness: readiness, strain: strain, sleepMin: sleepMin, needMin: needMin, hrv: hrv, hrvBaseline: hrvBase, rhr: rhr, coachLine: coachLine) } @@ -250,13 +261,41 @@ private struct TripleRings: View { } } +/// Readiness headline row — big ring + score + what it blends. +private struct ReadinessRow: View { + let e: OpenStrapEntry + var ring: CGFloat = 64 + var body: some View { + HStack(spacing: 12) { + ZStack { + Ring(t: e.readinessT, color: e.readinessColor, lineWidth: 9) + Text(e.readiness >= 0 ? "\(e.readiness)" : "—").font(numFont(22)).foregroundColor(e.readinessColor) + } + .frame(width: ring, height: ring) + VStack(alignment: .leading, spacing: 2) { + Text("READINESS").font(.system(size: 10, weight: .semibold)).tracking(1.1).foregroundColor(.inkMuted) + Text(e.readiness >= 0 ? "HRV recovery + sleep" : "Building baseline") + .font(.system(size: 12)).foregroundColor(.ink) + } + Spacer(minLength: 0) + } + } +} + private struct SmallView: View { let e: OpenStrapEntry var body: some View { + // 2×2: Readiness · Strain / Sleep · HRV. VStack(spacing: 10) { - TripleRings(e: e, size: 46, line: 6, valueSize: 13) - if !e.hasData { - Text("Wear + sync").font(.system(size: 10)).foregroundColor(.inkMuted) + HStack(spacing: 0) { + MetricRing(label: "READY", value: e.readiness >= 0 ? "\(e.readiness)" : "—", + t: e.readinessT, color: e.readinessColor, size: 44, line: 6, valueSize: 13).frame(maxWidth: .infinity) + MetricRing(label: "STRAIN", value: e.strain >= 0 ? String(format: "%.1f", e.strain) : "—", + t: e.strainT, color: .coral, size: 44, line: 6, valueSize: 13).frame(maxWidth: .infinity) + } + HStack(spacing: 0) { + MetricRing(label: "SLEEP", value: hm(e.sleepMin), t: e.sleepT, color: .sleepBlue, size: 44, line: 6, valueSize: 12).frame(maxWidth: .infinity) + MetricRing(label: "HRV", value: e.hrv >= 0 ? "\(e.hrv)" : "—", t: e.hrvT, color: e.hrvColor, size: 44, line: 6, valueSize: 13).frame(maxWidth: .infinity) } } .padding(12) @@ -267,12 +306,8 @@ private struct MediumView: View { let e: OpenStrapEntry var body: some View { VStack(alignment: .leading, spacing: 12) { - TripleRings(e: e, size: 64, line: 8, valueSize: 17) - if !e.coachLine.isEmpty { - Text(e.coachLine).font(.system(size: 12, weight: .medium)).foregroundColor(.ink).lineLimit(2) - } else if !e.hasData { - Text("Wear + sync to see today").font(.system(size: 12)).foregroundColor(.inkMuted).lineLimit(2) - } + ReadinessRow(e: e) + TripleRings(e: e, size: 56, line: 7, valueSize: 15) } .frame(maxWidth: .infinity, alignment: .leading) .padding(16) @@ -283,10 +318,10 @@ private struct MediumView: View { private struct AccessoryCircularView: View { let e: OpenStrapEntry var body: some View { - Gauge(value: e.strainT) { - Text("STR") + Gauge(value: e.readinessT) { + Text("RDY") } currentValueLabel: { - Text(e.strain >= 0 ? String(format: "%.0f", e.strain) : "—") + Text(e.readiness >= 0 ? "\(e.readiness)" : "—") } .gaugeStyle(.accessoryCircular) .widgetAccentable() @@ -298,7 +333,7 @@ private struct AccessoryRectangularView: View { let e: OpenStrapEntry var body: some View { VStack(alignment: .leading, spacing: 2) { - Text("OpenStrap").font(.system(size: 11, weight: .bold)).widgetAccentable() + Text("Readiness \(e.readiness >= 0 ? "\(e.readiness)" : "—")").font(.system(size: 13, weight: .bold)).widgetAccentable() Text("Strain \(e.strain >= 0 ? String(format: "%.1f", e.strain) : "—") HRV \(e.hrv >= 0 ? "\(e.hrv)" : "—")") .font(.system(size: 13, weight: .semibold)) Text("Sleep \(hm(e.sleepMin))" + (e.rhr >= 0 ? " RHR \(e.rhr)" : "")) @@ -337,7 +372,7 @@ struct OpenStrapWidgetEntryView: View { case .accessoryCircular: AccessoryCircularView(e: entry) case .accessoryRectangular: AccessoryRectangularView(e: entry) case .accessoryInline: - Text("Strain \(entry.strain >= 0 ? String(format: "%.1f", entry.strain) : "—") · HRV \(entry.hrv >= 0 ? "\(entry.hrv)" : "—")") + Text("Ready \(entry.readiness >= 0 ? "\(entry.readiness)" : "—") · Strain \(entry.strain >= 0 ? String(format: "%.1f", entry.strain) : "—")") default: SmallView(e: entry) } } else { @@ -355,7 +390,7 @@ struct OpenStrapWidget: Widget { OpenStrapWidgetEntryView(entry: entry) } .configurationDisplayName("OpenStrap") - .description("Your strain, sleep and HRV at a glance.") + .description("Readiness, strain, sleep and HRV at a glance.") .supportedFamilies(supportedFamilies) } diff --git a/lib/models/payloads.dart b/lib/models/payloads.dart index 3fced36..5d3996a 100644 --- a/lib/models/payloads.dart +++ b/lib/models/payloads.dart @@ -91,6 +91,11 @@ class TodayData { // Recovery is HRV-based now (replaces the old heuristic readiness). Kept the // getter name `readiness` is dropped in favour of `recovery`. Metric get recovery => metricOf(_daily, 'recovery'); + // Composite Readiness (HRV ∩ sleep ∩ dip ∩ arousal) — the Today/widget headline. + Metric get readiness => metricOf(_daily, 'readiness'); + Metric get vo2max => metricOf(_daily, 'vo2max'); + Metric get fitness => metricOf(_daily, 'fitness'); + Metric get form => metricOf(_daily, 'form'); Metric get strain => metricOf(_daily, 'strain'); Metric get restingHr => metricOf(_daily, 'resting_hr'); Metric get rhrDelta => metricOf(_daily, 'resting_hr_delta'); diff --git a/lib/ui/today/today_screen.dart b/lib/ui/today/today_screen.dart index f7cfd75..5c697d2 100644 --- a/lib/ui/today/today_screen.dart +++ b/lib/ui/today/today_screen.dart @@ -251,6 +251,11 @@ class _TodayScreenState extends State _bodyAlert(alert), const SizedBox(height: Sp.x4), ], + // Composite Readiness headline (abstains until HRV exists). + if (!t.readiness.isEmpty) ...[ + _readinessHero(t), + const SizedBox(height: Sp.x4), + ], // At-a-glance gauges: Strain / Sleep / HRV. _dashboard(t), const SizedBox(height: Sp.x4), @@ -429,6 +434,37 @@ class _TodayScreenState extends State ); } + /// Composite Readiness hero — the day's headline. Ring + score + what it blends. + Widget _readinessHero(TodayData t) { + final r = t.readiness; + final score = r.isEmpty ? null : r.value!.round(); + final tcol = score == null + ? AppColors.inkMuted + : (score >= 66 ? AppColors.good : score >= 40 ? AppColors.coral : AppColors.coralDeep); + return GlowCard( + padding: const EdgeInsets.all(Sp.x6), + glow: tcol, + child: Row(children: [ + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Row(children: [ + const AppIcon(Ic.recovery, size: 16, color: AppColors.coralDeep), + const SizedBox(width: Sp.x2), + Text('READINESS', style: AppText.overline), + ]), + const SizedBox(height: Sp.x3), + Text(score == null ? '—' : '$score', style: AppText.display.copyWith(color: tcol)), + const SizedBox(height: Sp.x2), + Text(score == null + ? 'Building baseline — needs nocturnal HRV' + : 'HRV recovery + sleep, blended', style: AppText.bodySoft), + ])), + if (score != null) + RingStat(t: (score / 100).clamp(0.0, 1.0), color: tcol, size: 96, stroke: 11, + center: Text('$score', style: AppText.metricSm.copyWith(color: tcol))), + ]), + ); + } + /// Three small gauges under the hero — the at-a-glance trio. Widget _dashboard(TodayData t) { final strainT = t.strain.isEmpty ? double.nan : t.strain.normalized(21); diff --git a/lib/widget/widget_service.dart b/lib/widget/widget_service.dart index 3353f53..8562cf3 100644 --- a/lib/widget/widget_service.dart +++ b/lib/widget/widget_service.dart @@ -41,7 +41,8 @@ class WidgetService { Future setI(String k, int v) => HomeWidget.saveWidgetData(k, v); await HomeWidget.saveWidgetData('has_data', !t.isEmpty); - // Widget shows three rings now: Strain · Sleep · HRV (recovery retired). + // Headline composite Readiness + the three rings (Strain · Sleep · HRV). + await setI('readiness', t.readiness.isEmpty ? -1 : t.readiness.value!.round()); await setI('hrv', hrv == null ? -1 : hrv.rmssd.round()); await setI('hrv_baseline', hrv?.baseline == null ? -1 : hrv!.baseline!.round()); await HomeWidget.saveWidgetData( From f48e75e34b5b2ecf9e58c014e34ae49f0d6961f3 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 14 Jun 2026 15:35:23 +0530 Subject: [PATCH 14/55] =?UTF-8?q?edge:=20Heart=20tab=20=E2=80=94=20HRV/SDN?= =?UTF-8?q?N/LF-HF/CV=20+=20nocturnal=20dip=20as=20tappable=20trend=20rows?= =?UTF-8?q?;=20Irregular-beat=20watch=20card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/ui/screens/detail_cards.dart | 69 +++++++++++++++++++++++++++----- lib/ui/screens/metric_row.dart | 8 ++++ 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/lib/ui/screens/detail_cards.dart b/lib/ui/screens/detail_cards.dart index 06bd011..599797e 100644 --- a/lib/ui/screens/detail_cards.dart +++ b/lib/ui/screens/detail_cards.dart @@ -11,6 +11,7 @@ import '../../theme/tokens.dart'; import '../kit/kit.dart'; import '../kit/charts.dart'; import 'metric_row.dart'; +import 'trend_screen.dart'; String hm(num? minutes) { if (minutes == null) return '—'; @@ -141,19 +142,22 @@ class HeartDayCard extends StatelessWidget { ])), ], - // HRV — full Task-Force suite, each with what-it-means. + // HRV — full Task-Force suite, each tappable into its trend. if (hrv != null) ...[ const SizedBox(height: Sp.x6), SectionHeader('Heart-rate variability'), MetricGroup([ - MetricRow(icon: Ic.pulse, accent: AppColors.good, label: 'RMSSD', info: infoFor('rmssd'), - value: '${hrv['rmssd'] ?? '—'}', unit: 'ms'), + TrendMetricRow(icon: Ic.pulse, accent: AppColors.good, label: 'RMSSD', info: infoFor('rmssd'), + value: '${hrv['rmssd'] ?? '—'}', unit: 'ms', metric: 'hrv', trendTitle: 'HRV (RMSSD)'), if (hrv['sdnn'] != null) - MetricRow(icon: Ic.pulse, accent: AppColors.good, label: 'SDNN', info: infoFor('sdnn'), - value: '${hrv['sdnn']}', unit: 'ms'), + TrendMetricRow(icon: Ic.pulse, accent: AppColors.good, label: 'SDNN', info: infoFor('sdnn'), + value: '${hrv['sdnn']}', unit: 'ms', metric: 'sdnn', trendTitle: 'HRV (SDNN)'), if (hrv['lf_hf'] != null) - MetricRow(icon: Ic.pulse, accent: AppColors.good, label: 'LF / HF', info: infoFor('lf_hf'), - value: '${hrv['lf_hf']}'), + TrendMetricRow(icon: Ic.pulse, accent: AppColors.good, label: 'LF / HF', info: infoFor('lf_hf'), + value: '${hrv['lf_hf']}', metric: 'lf_hf', trendTitle: 'LF / HF'), + if (hrv['cv'] != null) + TrendMetricRow(icon: Ic.chart, accent: AppColors.good, label: 'HRV stability', info: infoFor('hrv_cv'), + value: '${hrv['cv']}', unit: '%', metric: 'hrv_cv', trendTitle: 'HRV stability (CV)'), if (hrv['baseline'] != null) MetricRow(icon: Ic.chart, accent: AppColors.inkSoft, label: 'Your baseline', info: 'Your typical RMSSD — recovery is measured against this.', @@ -203,8 +207,9 @@ class HeartDayCard extends StatelessWidget { MetricRow(icon: Ic.moon, accent: AppColors.loadDetraining, label: 'Sleeping HR', info: infoFor('sleeping_hr'), value: '${noct['sleeping_hr_avg']}', unit: 'bpm'), if (noct['dip_pct'] != null) - MetricRow(icon: Ic.down, accent: AppColors.good, label: 'Nocturnal dip', - info: infoFor('nocturnal_dip'), value: '${((noct['dip_pct'] as num) * 100).round()}', unit: '%'), + TrendMetricRow(icon: Ic.down, accent: AppColors.good, label: 'Nocturnal dip', + info: infoFor('dip'), value: '${((noct['dip_pct'] as num) * 100).round()}', unit: '%', + metric: 'dip', trendTitle: 'Nocturnal HR dip'), if (noct['vs_baseline_bpm'] != null) MetricRow(icon: Ic.chart, accent: AppColors.inkSoft, label: 'vs baseline', info: 'Tonight vs your typical sleeping heart rate.', @@ -233,6 +238,13 @@ class HeartDayCard extends StatelessWidget { _IllnessCard(illness), ], + // Irregular-rhythm screen (Poincaré from nocturnal RR) — shown once it has data. + if (d['irregular'] is Map && (d['irregular']['confidence'] as num? ?? 0) > 0) ...[ + const SizedBox(height: Sp.x6), + SectionHeader('Irregular-beat watch'), + _IrregularCard(d['irregular'] as Map), + ], + // What affected this — display-only (no navigation loop), properly padded. if (heartDrivers.isNotEmpty) ...[ const SizedBox(height: Sp.x6), @@ -321,6 +333,45 @@ class _IllnessCard extends StatelessWidget { } } +// Irregular-rhythm screen card — green "looks regular" vs amber "irregular pattern". +// A SCREEN, not a diagnosis. Conservative; shows the Poincaré descriptors. +class _IrregularCard extends StatelessWidget { + final Map irr; + const _IrregularCard(this.irr); + @override + Widget build(BuildContext context) { + final flag = irr['flag'] == true; + final accent = flag ? AppColors.warn : AppColors.good; + final softBg = flag ? AppColors.warnSoft : AppColors.goodSoft; + return ProCard(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding(padding: const EdgeInsets.all(Sp.x4), child: Row(children: [ + Container( + padding: const EdgeInsets.all(9), + decoration: BoxDecoration(color: softBg, borderRadius: BorderRadius.circular(R.chip)), + child: AppIcon(flag ? Ic.info : Ic.check, size: 17, color: accent), + ), + const SizedBox(width: Sp.x3), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(flag ? 'Irregular rhythm pattern' : 'Rhythm looks regular', + style: AppText.label.copyWith(color: accent)), + const SizedBox(height: 2), + Text(flag + ? 'Your beat-to-beat timing was unusually irregular overnight. A screen, not a diagnosis — if it persists, see a clinician.' + : 'Beat-to-beat timing was within a normal range overnight.', + style: AppText.captionMuted), + ])), + ])), + if (irr['sd1'] != null && irr['sd2'] != null) ...[ + const Divider(height: 1, color: AppColors.divider), + Padding(padding: const EdgeInsets.symmetric(horizontal: Sp.x4, vertical: Sp.x2), child: Column(children: [ + DetailRow(label: 'Poincaré SD1 / SD2', value: '${irr['sd1']} / ${irr['sd2']} ms'), + if (irr['pnn50'] != null) DetailRow(label: 'pNN50', value: '${irr['pnn50']}%'), + ])), + ], + ])); + } +} + // ── WEAR TIME ──────────────────────────────────────────────────────────────── // How long the strap was on the wrist for a day: a coverage ring + worn time hero, // a 24-hour coverage strip (minutes worn each hour), and when it went on/off. diff --git a/lib/ui/screens/metric_row.dart b/lib/ui/screens/metric_row.dart index 6d7deac..e66787f 100644 --- a/lib/ui/screens/metric_row.dart +++ b/lib/ui/screens/metric_row.dart @@ -36,6 +36,14 @@ const Map kMetricInfo = { 'hrr60': 'How fast your HR drops a minute after peak effort — fitness marker.', 'illness': 'A combined resting-HR / HRV / temperature signal that can flag early illness.', 'debt': 'Sleep you owe from falling short of your need on recent nights.', + 'hrv_cv': 'How steady your nightly HRV is — lower, stable is better.', + 'readiness': 'A blend of HRV recovery and sleep — your day-ahead capacity.', + 'vo2max': 'Estimated aerobic fitness from your max vs resting heart rate.', + 'form': 'Freshness: fitness minus fatigue. Positive means well-rested.', + 'fitness': 'Chronic training load — your built-up fitness (Banister).', + 'fatigue': 'Acute training load — recent fatigue (Banister).', + 'monotony': 'Sameness of daily strain — very high can raise injury risk.', + 'dip': 'How far your heart rate falls in sleep — a bigger dip is better.', }; String? infoFor(String key) => kMetricInfo[key]; From f439cb41860cf0ea134eba67e738d93566f105e4 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 14 Jun 2026 15:37:12 +0530 Subject: [PATCH 15/55] =?UTF-8?q?edge:=20Body=20tab=20=E2=80=94=20Fitness?= =?UTF-8?q?=20section=20(VO2max,=20Banister=20fitness/fatigue/form=20chart?= =?UTF-8?q?,=20ACWR=20+=20monotony=20trends)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/ui/activity/strain_detail_screen.dart | 105 ++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/lib/ui/activity/strain_detail_screen.dart b/lib/ui/activity/strain_detail_screen.dart index e4b4154..4c09a84 100644 --- a/lib/ui/activity/strain_detail_screen.dart +++ b/lib/ui/activity/strain_detail_screen.dart @@ -10,6 +10,8 @@ import '../../theme/theme.dart'; import '../../theme/tokens.dart'; import '../kit/kit.dart'; import '../kit/charts.dart'; +import '../screens/metric_row.dart'; +import '../screens/trend_screen.dart'; class StrainDetailScreen extends StatefulWidget { final String date; // 'YYYY-MM-DD' @@ -206,6 +208,7 @@ class _StrainDetailScreenState extends State { _loadCard(load, fitness, cals, steps), const SizedBox(height: Sp.x4), ], + ..._fitnessSection(), _curveCard(), const SizedBox(height: Sp.x4), _zonesCard(), @@ -225,6 +228,41 @@ class _StrainDetailScreenState extends State { ]; } + // Fitness modeling — VO₂max, Banister fitness/fatigue/form curves, monotony. + List _fitnessSection() { + final vo2 = _num(_data['vo2max']); + final fm = _map(_data['fitness_model']); + final monotony = _num(_data['monotony']); + final acwr = _num(_map(_data['load'])['acwr']); + final rows = []; + if (vo2 != null) { + rows.add(TrendMetricRow(icon: Ic.pulse, accent: AppColors.good, label: 'VO₂max', + info: infoFor('vo2max'), value: vo2.toStringAsFixed(1), unit: 'ml/kg/min', + metric: 'vo2max', trendTitle: 'VO₂max')); + } + if (acwr != null) { + rows.add(TrendMetricRow(icon: Ic.strain, accent: AppColors.coral, label: 'Training load (ACWR)', + info: infoFor('load'), value: acwr.toStringAsFixed(2), + metric: 'acwr', trendTitle: 'Training load (ACWR)')); + } + if (monotony != null) { + rows.add(TrendMetricRow(icon: Ic.chart, accent: AppColors.warn, label: 'Monotony', + info: infoFor('monotony'), value: monotony.toStringAsFixed(2), + metric: 'monotony', trendTitle: 'Training monotony')); + } + final hasModel = fm['fitness'] != null || fm['fatigue'] != null || fm['form'] != null; + if (rows.isEmpty && !hasModel) return const []; + return [ + const SectionHeader('Fitness'), + if (hasModel) ...[ + _FitnessModelCard(fitness: _num(fm['fitness']), fatigue: _num(fm['fatigue']), form: _num(fm['form'])), + const SizedBox(height: Sp.x3), + ], + if (rows.isNotEmpty) MetricGroup(rows), + const SizedBox(height: Sp.x4), + ]; + } + Widget _loadCard(Map load, String? fitness, num? cals, num? steps) { final acwr = _num(load['acwr']); final band = load['band']?.toString(); @@ -555,3 +593,70 @@ class _StrainDetailScreenState extends State { ); } } + +/// Banister Fitness/Fatigue/Form — today's values + a dual-line trend (fetched +/// from /trend/fitness & /trend/fatigue). The Body tab's fitness centerpiece. +class _FitnessModelCard extends StatefulWidget { + final num? fitness; + final num? fatigue; + final num? form; + const _FitnessModelCard({this.fitness, this.fatigue, this.form}); + @override + State<_FitnessModelCard> createState() => _FitnessModelCardState(); +} + +class _FitnessModelCardState extends State<_FitnessModelCard> { + List _fit = const []; + List _fat = const []; + bool _loading = true; + @override + void initState() { super.initState(); _go(); } + Future _go() async { + final api = context.read().api; + if (api == null) { setState(() => _loading = false); return; } + try { + final f = await api.getTrend('fitness', scale: 'quarter'); + final g = await api.getTrend('fatigue', scale: 'quarter'); + List vals(Map d) => [ + for (final b in ((d['buckets'] as List?) ?? const [])) + ((b as Map)['value'] as num?)?.toDouble() + ]; + if (mounted) setState(() { _fit = vals(f); _fat = vals(g); _loading = false; }); + } catch (_) { if (mounted) setState(() => _loading = false); } + } + + Widget _stat(String label, num? v, Color c) => Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(v == null ? '—' : v.toStringAsFixed(1), style: AppText.metricSm.copyWith(fontSize: 20, color: c)), + Text(label, style: AppText.captionMuted), + ]), + ); + + @override + Widget build(BuildContext context) { + return ProCard(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Fitness · Fatigue · Form', style: AppText.label), + const SizedBox(height: Sp.x1), + Text('Built-up fitness vs recent fatigue. Form = fitness − fatigue (freshness).', + style: AppText.captionMuted), + const SizedBox(height: Sp.x3), + Row(children: [ + _stat('FITNESS', widget.fitness, AppColors.coral), + _stat('FATIGUE', widget.fatigue, AppColors.loadDetraining), + _stat('FORM', widget.form, widget.form != null && widget.form! >= 0 ? AppColors.good : AppColors.warn), + ]), + if (!_loading && (_fit.where((v) => v != null).length > 1 || _fat.where((v) => v != null).length > 1)) ...[ + const SizedBox(height: Sp.x4), + FormChart(fitness: _fit, fatigue: _fat), + const SizedBox(height: Sp.x2), + Row(children: [ + _legendDot(AppColors.coral), Text(' Fitness ', style: AppText.caption), + _legendDot(AppColors.loadDetraining), Text(' Fatigue', style: AppText.caption), + ]), + ], + ])); + } + + Widget _legendDot(Color c) => Container(width: 9, height: 9, + decoration: BoxDecoration(color: c, shape: BoxShape.circle)); +} From 95e5ccef87b6477b90c766911231e2a27ff551f0 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 14 Jun 2026 15:39:20 +0530 Subject: [PATCH 16/55] edge: Sleep tab Trends group (time asleep / efficiency / deep / REM / SRI as tappable trend rows) + metric info entries --- lib/ui/screens/metric_row.dart | 1 - lib/ui/sleep/sleep_detail_screen.dart | 24 ++++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/ui/screens/metric_row.dart b/lib/ui/screens/metric_row.dart index e66787f..e6f2110 100644 --- a/lib/ui/screens/metric_row.dart +++ b/lib/ui/screens/metric_row.dart @@ -40,7 +40,6 @@ const Map kMetricInfo = { 'readiness': 'A blend of HRV recovery and sleep — your day-ahead capacity.', 'vo2max': 'Estimated aerobic fitness from your max vs resting heart rate.', 'form': 'Freshness: fitness minus fatigue. Positive means well-rested.', - 'fitness': 'Chronic training load — your built-up fitness (Banister).', 'fatigue': 'Acute training load — recent fatigue (Banister).', 'monotony': 'Sameness of daily strain — very high can raise injury risk.', 'dip': 'How far your heart rate falls in sleep — a bigger dip is better.', diff --git a/lib/ui/sleep/sleep_detail_screen.dart b/lib/ui/sleep/sleep_detail_screen.dart index 83f3aa8..19de943 100644 --- a/lib/ui/sleep/sleep_detail_screen.dart +++ b/lib/ui/sleep/sleep_detail_screen.dart @@ -10,6 +10,8 @@ import '../../theme/theme.dart'; import '../../theme/tokens.dart'; import '../kit/kit.dart'; import '../kit/charts.dart'; +import '../screens/metric_row.dart'; +import '../screens/trend_screen.dart'; class SleepDetailScreen extends StatefulWidget { final String date; // 'YYYY-MM-DD' @@ -265,6 +267,28 @@ class _SleepDetailScreenState extends State { SectionHeader('Nocturnal heart'), _nocturnalCard(), ], + // Tap any of these into its Week/Month/3M trend. + const SizedBox(height: Sp.x6), + const SectionHeader('Trends'), + MetricGroup([ + TrendMetricRow(icon: Ic.moon, accent: AppColors.loadDetraining, label: 'Time asleep', + info: infoFor('sleep'), value: _hm(_durationMin), metric: 'sleep', trendTitle: 'Sleep', + valueFmt: (v) => v == 0 ? '' : (v / 60).toStringAsFixed(1)), + if (_efficiency != null) + TrendMetricRow(icon: Ic.chart, accent: AppColors.good, label: 'Efficiency', + info: infoFor('efficiency'), value: '${(_efficiency! * 100).round()}', unit: '%', + metric: 'efficiency', trendTitle: 'Sleep efficiency'), + if (_deepMin != null) + TrendMetricRow(icon: Ic.pulse, accent: AppColors.coral, label: 'Deep sleep', + info: infoFor('deep'), value: _hm(_deepMin), metric: 'deep', trendTitle: 'Deep sleep'), + if (_remMin != null) + TrendMetricRow(icon: Ic.pulse, accent: AppColors.coralSoft, label: 'REM sleep', + info: infoFor('rem'), value: _hm(_remMin), metric: 'rem', trendTitle: 'REM sleep'), + if (_regularity != null) + TrendMetricRow(icon: Ic.calendar, accent: AppColors.good, label: 'Regularity (SRI)', + info: infoFor('regularity'), value: '${_regularity!.round()}', metric: 'regularity', + trendTitle: 'Sleep regularity (SRI)'), + ]), ]; } From 7442a2148bd6981c78758feae329745b6d95a258 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 14 Jun 2026 16:06:07 +0530 Subject: [PATCH 17/55] =?UTF-8?q?edge:=20interactive=20live=20workout=20sc?= =?UTF-8?q?reen=20=E2=80=94=20HR-reactive=20ember=20+=20zone=20ladder=20+?= =?UTF-8?q?=20streaks=20+=20milestones=20+=20playful=20lines=20+=20Home=20?= =?UTF-8?q?live=20banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - picker now starts the LOCAL live engine (live HR UI + iOS Live Activity + global state) alongside the backend session, opens the rich LiveSessionScreen - LiveSessionScreen rebuilt: ember core beats at real HR, ember particle field scales with effort, zone ladder with 'X bpm to Z4' nudge, in-the-red streak, zone-up + milestone callouts with hand-drawn confetti, playful line engine, haptics; hold-to-finish → backend end + breakdown - app.dart: persistent _LiveBanner above navbar (live HR + elapsed, tap to resume) shown on any tab while a workout runs - app_state: workout carries backend id + type + maxHrSeen - removed the old plain LiveWorkoutScreen --- lib/app.dart | 68 +- lib/state/app_state.dart | 17 +- lib/ui/activity/live_session_screen.dart | 954 +++++++++++------------ lib/ui/workouts/workouts_screen.dart | 88 +-- 4 files changed, 552 insertions(+), 575 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 57ee63d..7cdf776 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -11,6 +11,7 @@ import 'ui/pairing_screen.dart'; import 'ui/today/today_screen.dart'; import 'ui/screens/screens.dart'; import 'ui/workouts/workouts_screen.dart'; +import 'ui/activity/live_session_screen.dart'; class OpenStrapApp extends StatefulWidget { const OpenStrapApp({super.key}); @@ -121,11 +122,68 @@ class _ShellState extends State<_Shell> { onPageChanged: (i) => setState(() => _index = i), children: [for (final p in _pages) _KeepAlive(child: p)], ), - bottomNavigationBar: _ScrubNav( - items: _nav, - controller: _controller, - index: _index, - onSelect: _go, + bottomNavigationBar: Column(mainAxisSize: MainAxisSize.min, children: [ + const _LiveBanner(), + _ScrubNav(items: _nav, controller: _controller, index: _index, onSelect: _go), + ]), + ); + } +} + +/// Persistent "workout in progress" mini-player — shows whenever a live workout is +/// running and you've navigated away from the live screen. Tap to jump back in. +class _LiveBanner extends StatefulWidget { + const _LiveBanner(); + @override + State<_LiveBanner> createState() => _LiveBannerState(); +} + +class _LiveBannerState extends State<_LiveBanner> with SingleTickerProviderStateMixin { + late final AnimationController _pulse = + AnimationController(vsync: this, duration: const Duration(milliseconds: 900))..repeat(reverse: true); + @override + void dispose() { _pulse.dispose(); super.dispose(); } + + String _fmt(Duration d) => + '${d.inMinutes.toString().padLeft(2, '0')}:${(d.inSeconds % 60).toString().padLeft(2, '0')}'; + + @override + Widget build(BuildContext context) { + final w = context.watch().activeWorkout; + if (w == null) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.fromLTRB(Sp.x6, 0, Sp.x6, Sp.x2), + child: GestureDetector( + onTap: () { + HapticFeedback.selectionClick(); + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => LiveSessionScreen(workoutId: w.workoutId, type: w.type))); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: Sp.x4, vertical: Sp.x3), + decoration: BoxDecoration( + color: AppColors.night, + borderRadius: BorderRadius.circular(R.pill), + boxShadow: Shadows.lift, + ), + child: Row(children: [ + FadeTransition(opacity: _pulse, child: Container( + width: 10, height: 10, + decoration: const BoxDecoration(color: AppColors.coral, shape: BoxShape.circle))), + const SizedBox(width: Sp.x3), + Text('LIVE · ${w.type.toUpperCase()}', style: AppText.overline.copyWith(color: Colors.white70)), + const Spacer(), + const AppIcon(Ic.heart, size: 15, color: AppColors.coral), + const SizedBox(width: 4), + Text(w.currentHr > 0 ? '${w.currentHr}' : '—', + style: AppText.metricSm.copyWith(color: Colors.white, fontSize: 16)), + const SizedBox(width: Sp.x4), + Text(_fmt(w.elapsed), style: AppText.metricSm.copyWith( + color: Colors.white60, fontSize: 15, fontFeatures: [const FontFeature.tabularFigures()])), + const SizedBox(width: Sp.x2), + const AppIcon(Ic.arrowRight, size: 16, color: Colors.white38), + ]), + ), ), ); } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index a44ecd7..bd6683d 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -500,12 +500,14 @@ class AppState extends ChangeNotifier { return 0; } - void startWorkout({double targetKcal = 300}) { + void startWorkout({double targetKcal = 300, String? workoutId, String type = 'other'}) { if (activeWorkout != null) return; final start = DateTime.now(); activeWorkout = LiveWorkoutState( startTime: start, targetKcal: targetKcal, + workoutId: workoutId, + type: type, ); _workoutTimer = Timer.periodic(const Duration(seconds: 1), (_) => _tickWorkout()); notifyListeners(); @@ -545,6 +547,7 @@ class AppState extends ChangeNotifier { w.elapsed = DateTime.now().difference(w.startTime); w.currentHr = device.liveHr ?? 0; + if (w.currentHr > w.maxHrSeen) w.maxHrSeen = w.currentHr; if (w.currentHr > 0) { // Calorie burn formula (estimate per second): @@ -593,10 +596,18 @@ class AppState extends ChangeNotifier { class LiveWorkoutState { final DateTime startTime; final double targetKcal; + final String? workoutId; // backend session id (for the breakdown on finish) + final String type; // exercise type label Duration elapsed = Duration.zero; double calories = 0.0; double strain = 0.0; int currentHr = 0; - - LiveWorkoutState({required this.startTime, required this.targetKcal}); + int maxHrSeen = 0; // peak live HR this session (for the "new max!" moment) + + LiveWorkoutState({ + required this.startTime, + required this.targetKcal, + this.workoutId, + this.type = 'other', + }); } diff --git a/lib/ui/activity/live_session_screen.dart b/lib/ui/activity/live_session_screen.dart index cb21079..85d244d 100644 --- a/lib/ui/activity/live_session_screen.dart +++ b/lib/ui/activity/live_session_screen.dart @@ -1,5 +1,8 @@ -// Live workout view — heart rate, zone, calories, and strain during a session. -// Dark theme; long-press to finish. +// Live workout — an interactive, HR-reactive screen that reacts to your heart in +// real time. Everything here is driven by REAL live HR (device.liveHr via BLE): +// an ember core that beats at your pulse, a zone ladder with "almost there" nudges, +// 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:math' as math; import 'dart:ui'; @@ -11,556 +14,533 @@ import '../../state/app_state.dart'; import '../../theme/theme.dart'; import '../../theme/tokens.dart'; import '../kit/kit.dart'; +import '../workouts/workouts_screen.dart' show WorkoutDetailScreen; class LiveSessionScreen extends StatefulWidget { - const LiveSessionScreen({super.key}); + final String? workoutId; // backend session id (for the breakdown on finish) + final String type; + const LiveSessionScreen({super.key, this.workoutId, this.type = 'other'}); @override State createState() => _LiveSessionScreenState(); } +// Zone metadata (0..5) — matches app_state's %max bands. +class _ZoneMeta { + final String label, name; + final Color color; + const _ZoneMeta(this.label, this.name, this.color); +} + +const List<_ZoneMeta> _zones = [ + _ZoneMeta('Z0', 'Resting', AppColors.cool), + _ZoneMeta('Z1', 'Warm-up', AppColors.loadDetraining), + _ZoneMeta('Z2', 'Fat burn', AppColors.good), + _ZoneMeta('Z3', 'Aerobic', AppColors.warn), + _ZoneMeta('Z4', 'Threshold', AppColors.coral), + _ZoneMeta('Z5', 'Max effort', AppColors.coralDeep), +]; +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. +const Map> _lines = { + 0: ["Heart's still sipping coffee.", "Easing in — no sprinting cold.", "Loosening the engine…"], + 1: ["Warm-up mode. We build to it.", "Blood's moving. Good start.", "Gentle. The fun comes later."], + 2: ["Cruising — the fat-burn sweet spot.", "Your mitochondria say thanks.", "Zone 2: the long-game zone."], + 3: ["Engine's humming. Hold this.", "Aerobic and honest. Keep rolling.", "This is the work. Stay here."], + 4: ["Threshold — this is where fitness is built.", "Breathe and hold. You've got this.", "The good kind of uncomfortable."], + 5: ["MAX. Brief and brutal — respect.", "Full send. Your heart filed a complaint.", "Don't quit. You're almost through it."], +}; +const List _droppingLines = [ + "HR's easing down — recover, or pick it back up?", + "Catching your breath. Smart.", + "Coasting. Ready when you are.", +]; + class _LiveSessionScreenState extends State with TickerProviderStateMixin { - late final AnimationController _beatController; - late final AnimationController _holdController; + late final AnimationController _beat; // HR pulse + late final AnimationController _hold; // hold-to-finish + late final AnimationController _fx; // ember field (continuous) + late final AnimationController _burst; // celebration confetti (one-shot) + + AppState? _app; + 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") + String? _calloutSub; + DateTime _calloutUntil = DateTime.fromMillisecondsSinceEpoch(0); + final List<_Particle> _confetti = []; + final _rand = math.Random(); + bool _ending = false; @override void initState() { super.initState(); - _beatController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 800), - )..repeat(reverse: true); - - _holdController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1500), - ); + _beat = AnimationController(vsync: this, duration: const Duration(milliseconds: 800))..repeat(reverse: true); + _hold = AnimationController(vsync: this, duration: const Duration(milliseconds: 1500)); + _fx = AnimationController(vsync: this, duration: const Duration(seconds: 3))..repeat(); + _burst = AnimationController(vsync: this, duration: const Duration(milliseconds: 1300)); + WidgetsBinding.instance.addPostFrameCallback((_) { + _app = context.read(); + _app!.addListener(_onTick); + _onTick(); + }); } @override void dispose() { - _beatController.dispose(); - _holdController.dispose(); + _app?.removeListener(_onTick); + _beat.dispose(); + _hold.dispose(); + _fx.dispose(); + _burst.dispose(); super.dispose(); } - void _onFinish() { - context.read().stopWorkout(); - Navigator.pop(context); - HapticFeedback.vibrate(); + double get _maxHr { + final age = (_app?.user?['age'] as num?)?.toDouble() ?? 30.0; + return 220.0 - age; } - String _formatDuration(Duration d) { - final m = d.inMinutes; - final s = d.inSeconds % 60; - return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}'; + int _zoneFor(int hr) { + if (hr <= 0) return 0; + final pct = hr / _maxHr; + for (int z = 5; z >= 1; z--) { + if (pct >= _zonePct[z]) return z; + } + return 0; } - ({String label, Color color}) _zone(int hr, double age) { - final maxHr = 220.0 - age; - final pct = hr / maxHr; - if (pct < 0.6) return (label: 'Z1 · Warm up', color: AppColors.loadDetraining); - if (pct < 0.7) return (label: 'Z2 · Fat burn', color: AppColors.good); - if (pct < 0.8) return (label: 'Z3 · Aerobic', color: AppColors.warn); - if (pct < 0.9) return (label: 'Z4 · Threshold', color: AppColors.coral); - return (label: 'Z5 · Max effort', color: AppColors.coralDeep); - } + // Per-second tick from AppState.notifyListeners — all side effects live here. + void _onTick() { + final w = _app?.activeWorkout; + if (w == null || !mounted) return; + final hr = w.currentHr; + final zone = _zoneFor(hr); + + // Beat at the real heart rate. + if (hr > 40) _beat.duration = Duration(milliseconds: (60000 / hr).round()); + + // In-the-red streak (continuous time in zone ≥3). + if (zone >= 3) { + _redStart ??= DateTime.now(); + _redStreak = DateTime.now().difference(_redStart!); + } else { + _redStart = null; + _redStreak = Duration.zero; + } - @override - Widget build(BuildContext context) { - final app = context.watch(); - final workout = app.activeWorkout; - if (workout == null) return const Scaffold(backgroundColor: AppColors.night); + // Zone-up moment → callout + heavy haptic + confetti. + if (_lastZone >= 0 && zone > _lastZone && zone >= 1) { + _fireCallout(_zones[zone].label, _zones[zone].name.toUpperCase()); + HapticFeedback.heavyImpact(); + if (zone >= 4) _fireConfetti(_zones[zone].color); + } else if (_lastZone >= 0 && zone < _lastZone && zone <= 2 && _lastZone >= 3) { + HapticFeedback.selectionClick(); + } - final hr = workout.currentHr; - final age = (app.user?['age'] as num?)?.toDouble() ?? 30.0; - final zone = _zone(hr, age); + // Milestones — time / calories / new max HR. + final mins = w.elapsed.inMinutes; + if (mins > 0 && mins % 5 == 0) _milestone('t$mins', '$mins MINUTES', "Locked in. Keep going.", AppColors.good); + final kcalStep = (w.calories ~/ 100) * 100; + if (kcalStep >= 100) _milestone('k$kcalStep', '$kcalStep KCAL', "Burning clean.", AppColors.coral); + if (w.elapsed.inSeconds > 90 && hr > 0 && hr == w.maxHrSeen && hr >= (_maxHr * 0.8)) { + _milestone('mhr$hr', 'NEW MAX · $hr', "Highest your heart's gone today.", AppColors.coralDeep); + } - // Adjust beat duration based on HR. - if (hr > 40) { - _beatController.duration = Duration(milliseconds: 60000 ~/ hr); + // Rotate the playful line ~every 11s (or on zone change). + final seed = w.elapsed.inSeconds ~/ 11; + if (seed != _lineSeed || zone != _lastZone) { + _lineSeed = seed; + final bucket = (zone < _lastZone && zone <= 2) ? _droppingLines : (_lines[zone] ?? _lines[0]!); + _line = bucket[(seed + zone) % bucket.length]; } - return Theme( - data: ThemeData.dark().copyWith( - scaffoldBackgroundColor: AppColors.night, - ), - child: Scaffold( - body: Stack( - children: [ - // 1. Deep "Studio" Background: primary zone glow. - Positioned.fill( - child: Container( - color: AppColors.night, - child: AnimatedContainer( - duration: Motion.slow, - decoration: BoxDecoration( - gradient: RadialGradient( - center: const Alignment(0, -0.2), - radius: 1.4, - colors: [ - zone.color.withValues(alpha: 0.25), - AppColors.night, - ], - ), - ), - ), - ), - ), + _lastZone = zone; + setState(() {}); + } - // 2. Timer Top Bar. - SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: Sp.x6), - child: Align( - alignment: Alignment.topCenter, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: Sp.x5, vertical: Sp.x2), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(R.pill), - border: Border.all(color: Colors.white10), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const AppIcon(Ic.clock, size: 16, color: Colors.white60), - const SizedBox(width: Sp.x3), - Text( - _formatDuration(workout.elapsed), - style: AppText.metricSm.copyWith( - color: Colors.white, - fontSize: 19, - letterSpacing: 0.5, - fontFeatures: [const FontFeature.tabularFigures()]), - ), - ], - ), - ), - ), - ), - ), + void _fireCallout(String big, String sub) { + _callout = big; + _calloutSub = sub; + _calloutUntil = DateTime.now().add(const Duration(seconds: 3)); + } - // 3. Immersive Core: The Ember Pulse. - Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Stack( - alignment: Alignment.center, - children: [ - // Target Progress Ring (Outer). - SizedBox( - width: 330, - height: 330, - child: CustomPaint( - painter: _GoalRingPainter( - progress: workout.calories / workout.targetKcal, - color: Colors.white.withValues(alpha: 0.05), - activeColor: AppColors.coral, - ), - ), - ), - - // Zone Arc (Inner). - SizedBox( - width: 270, - height: 270, - child: CustomPaint( - painter: _ZoneArcPainter( - hr: hr, - maxHr: 220.0 - age, - color: zone.color, - ), - ), - ), - - // THE PULSE (Layered Animation). - AnimatedBuilder( - animation: _beatController, - builder: (context, child) { - final v = _beatController.value; - final curve = hr > 160 ? Curves.elasticOut : Curves.easeInOut; - final animatedV = curve.transform(v); - - final scale = 1.0 + (0.08 * animatedV); - final glowOpacity = 0.4 + (0.6 * animatedV); - - return Container( - width: 210, - height: 210, - decoration: BoxDecoration( - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: zone.color.withValues(alpha: 0.4 * glowOpacity), - blurRadius: 40 * scale, - spreadRadius: 2, - ), - BoxShadow( - color: zone.color.withValues(alpha: 0.15 * glowOpacity), - blurRadius: 100 * scale, - spreadRadius: 10, - ), - ], - ), - child: Transform.scale( - scale: scale, - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: AppColors.night, - border: Border.all( - color: zone.color.withValues(alpha: 0.3), - width: 1.5, - ), - ), - alignment: Alignment.center, - child: child, - ), - ), - ); - }, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - hr > 0 ? hr.toString() : '—', - style: AppText.display.copyWith( - fontSize: 92, - color: Colors.white, - height: 1, - fontWeight: FontWeight.w900, - ), - ), - const SizedBox(height: 4), - Text( - 'BPM', - style: AppText.overline.copyWith( - color: Colors.white38, - fontSize: 11, - letterSpacing: 5, - fontWeight: FontWeight.w800, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: Sp.x10), - const SizedBox(height: 8), - - // Zone Indicator. - AnimatedDefaultTextStyle( - duration: Motion.med, - style: AppText.h2.copyWith( - color: zone.color, - letterSpacing: 4, - fontWeight: FontWeight.w900, - fontSize: 22, - ), - child: Text(zone.label.toUpperCase()), - ), - const SizedBox(height: Sp.x2), - Text( - hr > 0 ? 'LEVEL: ${(hr / (220 - age) * 100).round()}%' : 'READY TO WORK?', - style: AppText.bodySoft.copyWith( - color: Colors.white24, - letterSpacing: 1.5, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), + void _milestone(String key, String big, String sub, Color c) { + if (_milestones.contains(key)) return; + _milestones.add(key); + _fireCallout(big, sub); + _fireConfetti(c); + HapticFeedback.mediumImpact(); + } - // 4. Floating Stat Panel. - Positioned( - left: Sp.x6, - right: Sp.x6, - bottom: Sp.x10, - child: _ControlPanel( - workout: workout, - holdController: _holdController, - onFinished: _onFinish, - ), - ), - ], - ), - ), - ); + void _fireConfetti(Color c) { + _confetti + ..clear() + ..addAll(List.generate(26, (_) { + final ang = -math.pi / 2 + (_rand.nextDouble() - 0.5) * 1.6; + final spd = 220 + _rand.nextDouble() * 320; + return _Particle( + vx: math.cos(ang) * spd, vy: math.sin(ang) * spd, + color: [c, Colors.white, AppColors.coralSoft][_rand.nextInt(3)], + size: 4 + _rand.nextDouble() * 5, + spin: (_rand.nextDouble() - 0.5) * 12, + ); + })); + _burst.forward(from: 0); } -} -class _ControlPanel extends StatelessWidget { - final LiveWorkoutState workout; - final AnimationController holdController; - final VoidCallback onFinished; + Future _finish() async { + if (_ending) return; + setState(() => _ending = true); + HapticFeedback.heavyImpact(); + final app = context.read(); + final id = widget.workoutId ?? app.activeWorkout?.workoutId; + app.stopWorkout(); // clears local + ends iOS Live Activity + try { if (id != null) await app.api?.endWorkout(id); } catch (_) {} + if (!mounted) return; + if (id != null) { + Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => WorkoutDetailScreen(id: id))); + } else { + Navigator.of(context).pop(); + } + } - const _ControlPanel({ - required this.workout, - required this.holdController, - required this.onFinished, - }); + String _fmt(Duration d) => + '${d.inMinutes.toString().padLeft(2, '0')}:${(d.inSeconds % 60).toString().padLeft(2, '0')}'; @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.x8), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(R.card), - border: Border.all(color: Colors.white10), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _Stat( - label: 'CALORIES', - value: workout.calories.round().toString(), - unit: 'kcal', - icon: Ic.fire, - ), - _Stat( - label: 'STRAIN', - value: workout.strain.toStringAsFixed(1), - unit: '', - icon: Ic.strain, - ), - _Stat( - label: 'BURN RATE', - value: (workout.calories / - math.max(1, workout.elapsed.inMinutes)) - .toStringAsFixed(1), - unit: 'm', - icon: Ic.pulse, - ), - ], + final app = context.read(); + final w = app.activeWorkout; + if (w == null) return const Scaffold(backgroundColor: AppColors.night); + final hr = w.currentHr; + final zone = _zoneFor(hr); + final z = _zones[zone]; + final hrrPct = hr <= 0 ? 0.0 : ((hr / _maxHr).clamp(0.0, 1.0)); + 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); + + return Theme( + data: ThemeData.dark().copyWith(scaffoldBackgroundColor: AppColors.night), + child: Scaffold( + body: Stack(children: [ + // 1. Zone-tinted studio background, intensity climbs 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], + )), + )), + + // 2. Ember field rising behind the core (count/heat ∝ effort). + Positioned.fill(child: AnimatedBuilder( + animation: _fx, + builder: (context, _) => CustomPaint(painter: _EmberPainter(t: _fx.value, intensity: hrrPct, color: z.color)), + )), + + // 3. Top: timer + in-the-red streak. + SafeArea(child: Padding( + padding: const EdgeInsets.symmetric(vertical: Sp.x5), + child: Column(children: [ + _pill(AppIcon(Ic.clock, size: 15, color: Colors.white60), _fmt(w.elapsed)), + if (_redStreak.inSeconds >= 5) ...[ + const SizedBox(height: Sp.x2), + _pill(const AppIcon(Ic.fire, size: 14, color: AppColors.coral), + '${_fmt(_redStreak)} in the red', tint: AppColors.coral), + ], + ]), + )), + + // 4. The ember core (beats at your HR). + 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). + Positioned(right: Sp.x4, top: 0, bottom: 0, child: Center(child: _zoneLadder(zone))), + + // 6. Stat panel + hold-to-finish. + Positioned(left: Sp.x6, right: Sp.x6, bottom: Sp.x8, + child: _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), + ]), + ))), + ]), + ), + ); + } + + 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)), ), - const SizedBox(height: Sp.x6), + 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()])), + ]), + ); - // High-End Hold to Finish. - GestureDetector( - onLongPressStart: (_) { - holdController.forward(); - HapticFeedback.lightImpact(); - }, - onLongPressEnd: (_) { - if (holdController.value < 1.0) { - holdController.reverse(); - } else { - onFinished(); - } - }, - child: AnimatedBuilder( - animation: holdController, - builder: (context, child) { - final val = holdController.value; - final scale = 1.0 - (0.05 * val); - - return Transform.scale( - scale: scale, - 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(Ic.cancel, - size: 20, - color: Color.lerp( - Colors.white24, Colors.white, val)), - const SizedBox(width: Sp.x3), - Text( - 'HOLD TO FINISH', - style: AppText.label.copyWith( - color: Color.lerp( - Colors.white38, Colors.white, val), - fontWeight: FontWeight.w900, - letterSpacing: 3, - fontSize: 13, - ), - ), - ], - ), - ], - ), - ), - ); - }, + 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), + ], + ]); } -class _Stat extends StatelessWidget { - final String label; - final String value; - final String unit; - final IconData icon; - - const _Stat({ - required this.label, - required this.value, - required this.unit, - required this.icon, - }); +// ── Ember particle field ────────────────────────────────────────────────────── +class _EmberPainter extends CustomPainter { + final double t; // 0..1 loop + final double intensity; // 0..1 (HR reserve) + final Color color; + _EmberPainter({required this.t, required this.intensity, required this.color}); @override - Widget build(BuildContext context) { - return Column( - children: [ - AppIcon(icon, size: 16, color: Colors.white38), - const SizedBox(height: Sp.x2), - Row( - 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)), - ], - ); + void paint(Canvas canvas, Size size) { + if (intensity <= 0.02) return; + final n = (10 + intensity * 46).round(); + final cx = size.width / 2; + final paint = Paint(); + for (int i = 0; i < n; i++) { + final phase = ((t + i / n) % 1.0); + final spread = size.width * (0.16 + 0.20 * intensity); + final x = cx + math.sin(i * 2.3 + phase * math.pi * 2) * spread * (0.4 + i % 3 * 0.3); + final y = size.height * 0.78 - phase * size.height * 0.66; + final op = (1 - phase) * (0.25 + 0.55 * intensity); + final r = (1.0 + (i % 4)) * (0.8 + intensity); + paint.color = color.withValues(alpha: op.clamp(0.0, 0.85)); + canvas.drawCircle(Offset(x, y), r, paint); + } } + + @override + bool shouldRepaint(_EmberPainter o) => o.t != t || o.intensity != intensity || o.color != color; } -class _GoalRingPainter extends CustomPainter { - final double progress; +// ── Zone arc (HR as % of max) ──────────────────────────────────────────────── +class _ZoneArcPainter extends CustomPainter { + final double pct; final Color color; - final Color activeColor; - - _GoalRingPainter({ - required this.progress, - required this.color, - required this.activeColor, - }); - + _ZoneArcPainter({required this.pct, required this.color}); @override void paint(Canvas canvas, Size size) { final center = size.center(Offset.zero); final radius = (size.width - 20) / 2; - final paint = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = 12 - ..strokeCap = StrokeCap.round - ..color = color; - - canvas.drawCircle(center, radius, paint); - - if (progress > 0) { - final activePaint = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = 12 - ..strokeCap = StrokeCap.round - ..color = activeColor; - canvas.drawArc( - Rect.fromCircle(center: center, radius: radius), - -math.pi / 2, - math.pi * 2 * progress.clamp(0.0, 1.0), - false, - activePaint, - ); - } + final track = Paint()..style = PaintingStyle.stroke..strokeWidth = 6..strokeCap = StrokeCap.round..color = Colors.white10; + canvas.drawArc(Rect.fromCircle(center: center, radius: radius), math.pi * 0.7, math.pi * 1.6, false, track); + final active = Paint()..style = PaintingStyle.stroke..strokeWidth = 9..strokeCap = StrokeCap.round..color = color; + canvas.drawArc(Rect.fromCircle(center: center, radius: radius), math.pi * 0.7, math.pi * 1.6 * pct.clamp(0.0, 1.0), false, active); } - @override - bool shouldRepaint(_GoalRingPainter old) => old.progress != progress; + bool shouldRepaint(_ZoneArcPainter o) => o.pct != pct || o.color != color; } -class _ZoneArcPainter extends CustomPainter { - final int hr; - final double maxHr; +// ── Confetti ────────────────────────────────────────────────────────────────── +class _Particle { + final double vx, vy, size, spin; final Color color; + _Particle({required this.vx, required this.vy, required this.color, required this.size, required this.spin}); +} - _ZoneArcPainter({ - required this.hr, - required this.maxHr, - required this.color, - }); - +class _ConfettiPainter extends CustomPainter { + final double t; // 0..1 + final List<_Particle> particles; + _ConfettiPainter({required this.t, required this.particles}); @override void paint(Canvas canvas, Size size) { - final center = size.center(Offset.zero); - final radius = (size.width - 20) / 2; - final pct = (hr / maxHr).clamp(0.0, 1.0); - - final paint = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = 6 - ..strokeCap = StrokeCap.round - ..color = Colors.white10; - - // Draw background track for zone (bottom half only). - canvas.drawArc( - Rect.fromCircle(center: center, radius: radius), - math.pi * 0.7, - math.pi * 1.6, - false, - paint, - ); + final origin = Offset(size.width / 2, size.height * 0.38); + final paint = Paint(); + for (final p in particles) { + final x = origin.dx + p.vx * t; + final y = origin.dy + p.vy * t + 360 * t * t; // gravity + paint.color = p.color.withValues(alpha: (1 - t).clamp(0.0, 1.0)); + canvas.save(); + canvas.translate(x, y); + canvas.rotate(p.spin * t); + canvas.drawRRect(RRect.fromRectAndRadius( + Rect.fromCenter(center: Offset.zero, width: p.size, height: p.size * 0.6), const Radius.circular(1)), paint); + canvas.restore(); + } + } + @override + bool shouldRepaint(_ConfettiPainter o) => o.t != t; +} - final activePaint = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = 8 - ..strokeCap = StrokeCap.round - ..color = color; - - canvas.drawArc( - Rect.fromCircle(center: center, radius: radius), - math.pi * 0.7, - math.pi * 1.6 * pct, - false, - activePaint, - ); +// ── 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; + const _ControlPanel({required this.workout, required this.holdController, required this.ending, required this.onFinished}); + + @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: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + _Stat(label: 'CALORIES', value: workout.calories.round().toString(), unit: 'kcal', icon: Ic.fire), + _Stat(label: 'STRAIN', value: workout.strain.toStringAsFixed(1), unit: '', icon: Ic.strain), + _Stat(label: 'BURN/MIN', + value: (workout.calories / math.max(1, workout.elapsed.inMinutes)).toStringAsFixed(1), + unit: '', icon: Ic.pulse), + ]), + ), + ), + ), + 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(Ic.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)), + ]), + ]), + ), + ); + }, + ), + ), + ]); } +} +class _Stat extends StatelessWidget { + final String label, value, unit; + final IconData icon; + const _Stat({required this.label, required this.value, required this.unit, required this.icon}); @override - bool shouldRepaint(_ZoneArcPainter old) => old.hr != hr || old.color != color; + Widget build(BuildContext context) { + return Column(children: [ + AppIcon(icon, size: 16, color: Colors.white38), + const SizedBox(height: Sp.x2), + Row(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)), + ]); + } } diff --git a/lib/ui/workouts/workouts_screen.dart b/lib/ui/workouts/workouts_screen.dart index 4af23a7..f40fb2d 100644 --- a/lib/ui/workouts/workouts_screen.dart +++ b/lib/ui/workouts/workouts_screen.dart @@ -7,6 +7,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../state/app_state.dart'; +import '../activity/live_session_screen.dart'; import '../../theme/theme.dart'; import '../../theme/tokens.dart'; import '../kit/kit.dart'; @@ -82,13 +83,18 @@ Future startWorkoutFlow(BuildContext context) async { ), ); if (type == null || !context.mounted) return; - final api = context.read().api; + final app = context.read(); + final api = app.api; if (api == null) return; try { final w = await api.startWorkout(type); + final id = w['workout_id'] as String?; if (!context.mounted) return; + // Start the LOCAL live engine (live HR UI + iOS Live Activity + global state) + // alongside the backend session, then open the interactive live screen. + app.startWorkout(workoutId: id, type: type); Navigator.of(context).push(MaterialPageRoute( - builder: (_) => LiveWorkoutScreen(workoutId: w['workout_id'] as String, type: type), + builder: (_) => LiveSessionScreen(workoutId: id, type: type), )); } catch (_) {/* surfaced as no-op; user can retry */} } @@ -322,84 +328,6 @@ class _WorkoutTile extends StatelessWidget { } } -/// Live workout — elapsed timer + recording state + Stop. The actual data records -/// via the existing background BLE keepalive (foreground service / iOS restoration), -/// so it keeps recording even if the app is backgrounded; the breakdown is computed -/// server-side on Stop regardless. If you forget to stop, the server auto-closes it -/// once your heart rate returns to baseline. -class LiveWorkoutScreen extends StatefulWidget { - final String workoutId; - final String type; - const LiveWorkoutScreen({super.key, required this.workoutId, required this.type}); - @override - State createState() => _LiveWorkoutScreenState(); -} - -class _LiveWorkoutScreenState extends State { - late final DateTime _start = DateTime.now(); - Timer? _t; - bool _ending = false; - - @override - void initState() { - super.initState(); - _t = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) setState(() {}); }); - } - - @override - void dispose() { _t?.cancel(); super.dispose(); } - - String get _elapsed { - final s = DateTime.now().difference(_start).inSeconds; - final h = s ~/ 3600, m = (s % 3600) ~/ 60, sec = s % 60; - return h > 0 ? '$h:${m.toString().padLeft(2, '0')}:${sec.toString().padLeft(2, '0')}' - : '$m:${sec.toString().padLeft(2, '0')}'; - } - - Future _stop() async { - final api = context.read().api; - if (api == null) return; - setState(() => _ending = true); - try { - await api.endWorkout(widget.workoutId); - if (!mounted) return; - Navigator.of(context).pushReplacement(MaterialPageRoute( - builder: (_) => WorkoutDetailScreen(id: widget.workoutId))); - } catch (_) { - if (mounted) setState(() => _ending = false); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.night, - body: SafeArea(child: Padding(padding: const EdgeInsets.all(Sp.x5), child: Column(children: [ - const Spacer(), - Text(widget.type.toUpperCase(), style: AppText.overline.copyWith(color: AppColors.onNightSoft)), - const SizedBox(height: Sp.x3), - Text(_elapsed, style: AppText.hero.copyWith(color: AppColors.onNight)), - const SizedBox(height: Sp.x3), - Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - Container(width: 8, height: 8, decoration: const BoxDecoration(color: AppColors.coral, shape: BoxShape.circle)), - const SizedBox(width: Sp.x2), - Text('Recording', style: AppText.body.copyWith(color: AppColors.onNightSoft)), - ]), - const SizedBox(height: Sp.x4), - Text('Keeps recording in the background — your data is safe even if you leave the app. ' - 'Forget to stop? It auto-ends when your heart rate settles.', - textAlign: TextAlign.center, style: AppText.caption.copyWith(color: AppColors.onNightSoft)), - const Spacer(), - SizedBox(width: double.infinity, child: FilledButton( - style: FilledButton.styleFrom(backgroundColor: AppColors.coral, padding: const EdgeInsets.symmetric(vertical: Sp.x4)), - onPressed: _ending ? null : _stop, - child: Text(_ending ? 'Finishing…' : 'Stop workout', style: AppText.label.copyWith(color: Colors.white)), - )), - const SizedBox(height: Sp.x4), - ]))), - ); - } -} /// Post-workout breakdown (also the tap target from the list). class WorkoutDetailScreen extends StatelessWidget { From 2a90a197fb997364b85a98de903564ed77b684ce Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 14 Jun 2026 16:13:21 +0530 Subject: [PATCH 18/55] =?UTF-8?q?edge:=20delete=20workouts=20=E2=80=94=20s?= =?UTF-8?q?wipe-to-delete=20on=20the=20list=20+=20delete=20action=20in=20t?= =?UTF-8?q?he=20breakdown,=20with=20confirm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/net/api_client.dart | 6 +++ lib/ui/workouts/workouts_screen.dart | 78 +++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/lib/net/api_client.dart b/lib/net/api_client.dart index 806f968..aa34cc2 100644 --- a/lib/net/api_client.dart +++ b/lib/net/api_client.dart @@ -248,6 +248,12 @@ class ApiClient { /// GET /workout/:id → one workout's breakdown + HR timeline. Future> getWorkout(String id) => _getObj('/workout/$id'); + /// DELETE /workout/:id → soft-delete (tombstone; auto-detect won't recreate it). + Future deleteWorkout(String id) async { + final resp = await _authed((h) => _client.delete(_u('/workout/$id'), headers: h)); + if (resp.statusCode != 200) throw ApiException(resp.statusCode, resp.body); + } + /// POST /workout/start {type} → {workout_id, start_ts, type, status}. Future> startWorkout(String type, {String? title}) async { final resp = await _authed((h) => _client.post(_u('/workout/start'), diff --git a/lib/ui/workouts/workouts_screen.dart b/lib/ui/workouts/workouts_screen.dart index f40fb2d..895164f 100644 --- a/lib/ui/workouts/workouts_screen.dart +++ b/lib/ui/workouts/workouts_screen.dart @@ -181,7 +181,7 @@ class _WorkoutsScreenState extends State { ))) else ...[ SectionHeader(_range == 0 ? 'Today' : 'Sessions'), - for (final w in list) _WorkoutTile(w as Map), + for (final w in list) _row(w as Map), ], ], ], @@ -190,6 +190,51 @@ class _WorkoutsScreenState extends State { ), ); } + + // Swipe-to-delete wrapper. Live sessions aren't deletable (finish them first). + Widget _row(Map w) { + final tile = _WorkoutTile(w); + if (w['status'] == 'live') return tile; + return Dismissible( + key: ValueKey(w['id']), + direction: DismissDirection.endToStart, + background: Container( + margin: const EdgeInsets.only(bottom: Sp.x3), + padding: const EdgeInsets.only(right: Sp.x6), + alignment: Alignment.centerRight, + decoration: BoxDecoration(color: AppColors.badSoft, borderRadius: BorderRadius.circular(R.card)), + child: const Row(mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.delete_outline, size: 20, color: AppColors.bad), + SizedBox(width: Sp.x2), + Text('Delete', style: TextStyle(color: AppColors.bad, fontWeight: FontWeight.w700)), + ]), + ), + confirmDismiss: (_) => _confirmDelete(w['id'] as String), + child: tile, + ); + } + + Future _confirmDelete(String id) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.surface, + title: Text('Delete this workout?', style: AppText.title), + content: Text('It will be removed for good. Auto-detected efforts won’t come back.', + style: AppText.bodySoft), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')), + TextButton(onPressed: () => Navigator.pop(ctx, true), + child: const Text('Delete', style: TextStyle(color: AppColors.bad, fontWeight: FontWeight.w700))), + ], + ), + ); + if (ok != true || !mounted) return false; + final api = context.read().api; + if (api == null) return false; + try { await api.deleteWorkout(id); _load(); return true; } + catch (_) { return false; } + } } /// Compact "blazing" Start pill — top-right of the Workouts header. Short, coral, @@ -333,11 +378,40 @@ class _WorkoutTile extends StatelessWidget { class WorkoutDetailScreen extends StatelessWidget { final String id; const WorkoutDetailScreen({super.key, required this.id}); + Future _delete(BuildContext context) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.surface, + title: Text('Delete this workout?', style: AppText.title), + content: Text('It will be removed for good. Auto-detected efforts won’t come back.', style: AppText.bodySoft), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')), + TextButton(onPressed: () => Navigator.pop(ctx, true), + child: const Text('Delete', style: TextStyle(color: AppColors.bad, fontWeight: FontWeight.w700))), + ], + ), + ); + if (ok != true || !context.mounted) return; + final api = context.read().api; + if (api == null) return; + try { await api.deleteWorkout(id); if (context.mounted) Navigator.of(context).pop(); } catch (_) {} + } + @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.bg, - appBar: AppBar(backgroundColor: AppColors.bg, elevation: 0, title: Text('Workout', style: AppText.title)), + appBar: AppBar( + backgroundColor: AppColors.bg, elevation: 0, title: Text('Workout', style: AppText.title), + actions: [ + IconButton( + icon: const Icon(Icons.delete_outline, color: AppColors.inkMuted), + tooltip: 'Delete workout', + onPressed: () => _delete(context), + ), + ], + ), body: _WorkoutDetailBody(id: id), ); } From 5ee965b634cd2c0200a6ff67ca27c6bfc1d1c1fa Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 14 Jun 2026 18:52:08 +0530 Subject: [PATCH 19/55] edge: OTA self-update + admin alert banner on Home (v0.3.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android installs the signed APK in-app (ota_update) with a browser fallback; iOS falls back to browser. App polls public GET /app/status on launch + every foreground resume. New StatusBanner on Today shows an update card (build-number gated, min_build forces it) and an admin-pushed banner (info/warn/critical; critical non-dismissible; dismissals persist). Banner respects an optional action_url — tappable 'Open →' when set, plain notice when not. Bump version 0.2.0+3 → 0.3.0+4 (build number must exceed installed builds for update detection). Adds package_info_plus, ota_update, url_launcher; REQUEST_INSTALL_PACKAGES on Android. --- android/app/src/main/AndroidManifest.xml | 2 + lib/app.dart | 1 + lib/models/app_status.dart | 87 +++++++ lib/net/api_client.dart | 9 + lib/state/app_state.dart | 55 +++++ lib/sync/update_service.dart | 57 +++++ lib/ui/today/today_screen.dart | 3 + lib/ui/widgets/status_banner.dart | 281 +++++++++++++++++++++++ pubspec.lock | 56 +++++ pubspec.yaml | 8 +- 10 files changed, 558 insertions(+), 1 deletion(-) create mode 100644 lib/models/app_status.dart create mode 100644 lib/sync/update_service.dart create mode 100644 lib/ui/widgets/status_banner.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a5ae36d..dc43bed 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -13,6 +13,8 @@ + + with WidgetsBindingObserver final app = context.read(); if (state == AppLifecycleState.resumed) { app.maybeFinishFromLiveActivity(); + app.refreshAppStatus(); // re-check OTA + admin banner on every foreground if (app.isAuthenticated && app.isPaired) app.openSession(); } else if (state == AppLifecycleState.paused) { // Backgrounded: hand the band to the iOS restore path so it can wake-and-drain diff --git a/lib/models/app_status.dart b/lib/models/app_status.dart new file mode 100644 index 0000000..ea6f153 --- /dev/null +++ b/lib/models/app_status.dart @@ -0,0 +1,87 @@ +// AppStatus — the payload of GET /app/status: an optional OTA update pointer and +// an optional admin-pushed home-screen alert banner. Parsed defensively; any +// missing field collapses to null so a partial/blank config never crashes. + +class UpdateInfo { + final String? latestVersion; // "0.3.0" (display only) + final int latestBuild; // monotonic build number; compared to ours + final String? apkUrl; // signed-APK download URL + final String? notes; // what's new + final int minBuild; // clients below this MUST update + + const UpdateInfo({ + this.latestVersion, + required this.latestBuild, + this.apkUrl, + this.notes, + this.minBuild = 0, + }); + + static UpdateInfo? fromJson(Map? j) { + if (j == null) return null; + final build = (j['latest_build'] as num?)?.toInt(); + if (build == null) return null; // no version published → no update info + return UpdateInfo( + latestVersion: j['latest_version'] as String?, + latestBuild: build, + apkUrl: j['apk_url'] as String?, + notes: j['notes'] as String?, + minBuild: (j['min_build'] as num?)?.toInt() ?? 0, + ); + } +} + +enum BannerLevel { info, warn, critical } + +BannerLevel _level(String? s) { + switch (s) { + case 'critical': + return BannerLevel.critical; + case 'warn': + return BannerLevel.warn; + default: + return BannerLevel.info; + } +} + +class BannerInfo { + final String id; // stable key so a dismissed banner stays dismissed + final String? title; + final String text; + final BannerLevel level; // critical → not dismissible + final String? actionUrl; // optional tap-through link + + const BannerInfo({ + required this.id, + this.title, + required this.text, + this.level = BannerLevel.info, + this.actionUrl, + }); + + bool get dismissible => level != BannerLevel.critical; + + static BannerInfo? fromJson(Map? j) { + if (j == null) return null; + final text = (j['text'] as String?)?.trim() ?? ''; + if (text.isEmpty && (j['title'] as String?)?.trim().isEmpty != false) return null; + return BannerInfo( + id: (j['id'] ?? '').toString(), + title: j['title'] as String?, + text: text, + level: _level(j['level'] as String?), + actionUrl: j['action_url'] as String?, + ); + } +} + +class AppStatus { + final UpdateInfo? update; + final BannerInfo? banner; + const AppStatus({this.update, this.banner}); + + static AppStatus fromJson(Map j) => AppStatus( + update: UpdateInfo.fromJson((j['update'] as Map?)?.cast()), + banner: BannerInfo.fromJson((j['banner'] as Map?)?.cast()), + ); +} diff --git a/lib/net/api_client.dart b/lib/net/api_client.dart index aa34cc2..cac303e 100644 --- a/lib/net/api_client.dart +++ b/lib/net/api_client.dart @@ -54,6 +54,15 @@ class ApiClient { Future> requestOtp(String email) => _postJson('/auth/request-otp', {'email': email}); + /// GET /app/status → { update, banner }. Public (no auth): the OTA prompt and a + /// service notice must work even with an expired session. Short timeout + + /// swallow failures at the call site (status is always best-effort). + Future> getAppStatus() async { + final resp = await http.get(_u('/app/status')).timeout(const Duration(seconds: 12)); + if (resp.statusCode != 200) throw ApiException(resp.statusCode, resp.body); + return _decode(resp.body); + } + /// Verify OTP → persists the session (access + refresh + user) and returns it. Future> verifyOtp(String email, String code) async { final r = await _postJson('/auth/verify-otp', {'email': email, 'code': code}); diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index bd6683d..34498a5 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -12,8 +12,10 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../models/app_status.dart'; import '../ble/ble_engine.dart'; import '../ble/ios_ble_restore.dart'; import '../data/db.dart'; @@ -74,6 +76,57 @@ class AppState extends ChangeNotifier { u['weight_kg'] != null; } + // ── app status: OTA update pointer + admin-pushed alert banner ────────────── + AppStatus? appStatus; + int _currentBuild = 0; // our build number (from package_info); 0 if unknown + final Set _dismissedBanners = {}; + + UpdateInfo? get _update => appStatus?.update; + + /// A newer build is published (we're behind latest_build). + bool get updateAvailable => + _update != null && _currentBuild > 0 && _update!.latestBuild > _currentBuild; + + /// We're below the mandatory floor — the prompt can't be dismissed. + bool get updateMandatory => + _update != null && _currentBuild > 0 && _currentBuild < _update!.minBuild; + + UpdateInfo? get update => _update; + + /// The admin banner to show right now (null if none, or dismissed + dismissible). + BannerInfo? get activeBanner { + final b = appStatus?.banner; + if (b == null) return null; + if (b.dismissible && _dismissedBanners.contains(b.id)) return null; + return b; + } + + Future _loadAppStatus() async { + try { + final info = await PackageInfo.fromPlatform(); + _currentBuild = int.tryParse(info.buildNumber) ?? 0; + } catch (_) {/* keep 0 → update prompts simply won't fire */} + final prefs = await SharedPreferences.getInstance(); + _dismissedBanners.addAll(prefs.getStringList('dismissed_banners') ?? const []); + await refreshAppStatus(); + } + + /// Re-poll /app/status (best-effort; called on launch and on app resume). + Future refreshAppStatus() async { + if (api == null) return; + try { + appStatus = AppStatus.fromJson(await api!.getAppStatus()); + notifyListeners(); + } catch (_) {/* best-effort — never disrupt the UI */} + } + + Future dismissBanner(String id) async { + _dismissedBanners.add(id); + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList('dismissed_banners', _dismissedBanners.toList()); + notifyListeners(); + } + AppState() { engine = BleEngine( onRecord: _onRecord, @@ -95,6 +148,8 @@ class AppState extends ChangeNotifier { _savedAlarm = (await SharedPreferences.getInstance()).getInt('alarm_epoch'); initialized = true; notifyListeners(); + // App status (OTA pointer + admin alert banner) — best-effort, non-blocking. + unawaited(_loadAppStatus()); // The flusher is connection-INDEPENDENT: it just uploads whatever's queued in // SQLite (and retries anything a prior tick failed to send). Start it as soon as // we're authenticated so a backlog drains even if the live connection comes up via diff --git a/lib/sync/update_service.dart b/lib/sync/update_service.dart new file mode 100644 index 0000000..a24d415 --- /dev/null +++ b/lib/sync/update_service.dart @@ -0,0 +1,57 @@ +// UpdateService — the Android OTA mechanics. There's no app store in the loop +// (the app is sideloaded), so we ARE the update channel: download the signed APK +// from the backend's update pointer and hand it to the system installer. The new +// APK must be signed with the same release key or Android refuses the update — +// which is already true for our CI-built GitHub releases. +// +// iOS can't sideload-install, so [supported] is false there and the UI hides OTA. + +import 'dart:io'; + +import 'package:ota_update/ota_update.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// A coarse progress event the UI renders. +class OtaProgress { + final String phase; // 'downloading' | 'installing' | 'error' + final int percent; // 0..100 while downloading + final String? message; + const OtaProgress(this.phase, {this.percent = 0, this.message}); +} + +class UpdateService { + /// Only Android can install an APK in-app. + static bool get supported => Platform.isAndroid; + + /// Download + launch the system installer for [apkUrl]. Emits progress; the + /// terminal 'installing' event means Android's install dialog is up. Errors + /// arrive either as an 'error' [OtaProgress] (known OTA failures) or on the + /// stream's error channel (unexpected) — the UI should fall back to + /// [openInBrowser] in both cases. + static Stream install(String apkUrl) { + if (!supported) { + return Stream.value(const OtaProgress('error', message: 'OTA is Android-only')); + } + return OtaUpdate() + .execute(apkUrl, destinationFilename: 'openstrap-update.apk') + .map((e) { + switch (e.status) { + case OtaStatus.DOWNLOADING: + return OtaProgress('downloading', percent: int.tryParse(e.value ?? '0') ?? 0); + case OtaStatus.INSTALLING: + return const OtaProgress('installing', percent: 100); + default: + // PERMISSION_NOT_GRANTED_ERROR, DOWNLOAD_ERROR, CHECKSUM_ERROR, etc. + return OtaProgress('error', message: '${e.status} ${e.value ?? ''}'.trim()); + } + }); + } + + /// Fallback: open the APK / release URL in the browser so the user can + /// download + install manually (also the only path on a denied install perm). + static Future openInBrowser(String url) async { + final uri = Uri.tryParse(url); + if (uri == null) return false; + return launchUrl(uri, mode: LaunchMode.externalApplication); + } +} diff --git a/lib/ui/today/today_screen.dart b/lib/ui/today/today_screen.dart index 5c697d2..ed00cb5 100644 --- a/lib/ui/today/today_screen.dart +++ b/lib/ui/today/today_screen.dart @@ -12,6 +12,7 @@ import '../../theme/tokens.dart'; import '../kit/kit.dart'; import '../kit/charts.dart'; import '../widgets/screen_loader.dart'; +import '../widgets/status_banner.dart'; import '../journal/journal_screen.dart'; import '../recap/recap_screen.dart'; import '../coach/coach_screen.dart'; @@ -114,6 +115,8 @@ class _TodayScreenState extends State children: [ const SizedBox(height: Sp.x4), _topBar(name), + // OTA update prompt + admin alert banner (admin-controlled, best-effort). + const StatusBanner(), if (freshnessLabel != null) ...[ const SizedBox(height: Sp.x3), _freshness(freshnessLabel!), diff --git a/lib/ui/widgets/status_banner.dart b/lib/ui/widgets/status_banner.dart new file mode 100644 index 0000000..55f43a0 --- /dev/null +++ b/lib/ui/widgets/status_banner.dart @@ -0,0 +1,281 @@ +// StatusBanner — the home-screen strip for two admin/ops signals: +// • an OTA "Update available" card (Android installs in-app; iOS / unsupported +// falls back to opening the download in a browser), and +// • an admin-pushed alert banner (info / warn / critical) set via the backend +// admin token. Critical banners can't be dismissed. +// Self-contained: reads AppState, renders nothing when there's nothing to show. + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../models/app_status.dart'; +import '../../state/app_state.dart'; +import '../../sync/update_service.dart'; +import '../../theme/theme.dart'; +import '../../theme/tokens.dart'; +import '../kit/kit.dart'; + +class StatusBanner extends StatelessWidget { + const StatusBanner({super.key}); + + @override + Widget build(BuildContext context) { + final app = context.watch(); + final banner = app.activeBanner; + final showUpdate = app.updateAvailable; + if (banner == null && !showUpdate) return const SizedBox.shrink(); + + return Column( + children: [ + if (showUpdate) ...[ + const SizedBox(height: Sp.x4), + _UpdateCard(update: app.update!, mandatory: app.updateMandatory), + ], + if (banner != null) ...[ + const SizedBox(height: Sp.x4), + _AlertCard(banner: banner, onDismiss: () => app.dismissBanner(banner.id)), + ], + ], + ); + } +} + +// ── OTA update card ─────────────────────────────────────────────────────────── +class _UpdateCard extends StatelessWidget { + final UpdateInfo update; + final bool mandatory; + const _UpdateCard({required this.update, required this.mandatory}); + + @override + Widget build(BuildContext context) { + final ver = update.latestVersion != null ? 'v${update.latestVersion}' : 'A new version'; + return ProCard( + color: AppColors.coralSoft, + onTap: () => _startUpdate(context, update), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(Sp.x3), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(R.chip), + ), + child: const AppIcon(Ic.cloud, size: 20, color: AppColors.coralDeep), + ), + const SizedBox(width: Sp.x4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(mandatory ? 'Update required' : 'Update available', style: AppText.title), + const SizedBox(height: 2), + Text('$ver is ready to install.', style: AppText.bodySoft), + ], + ), + ), + const SizedBox(width: Sp.x2), + Container( + padding: const EdgeInsets.symmetric(horizontal: Sp.x4, vertical: Sp.x2), + decoration: BoxDecoration( + color: AppColors.coralDeep, + borderRadius: BorderRadius.circular(R.pill), + ), + child: Text('Update', + style: AppText.label.copyWith(color: Colors.white, fontWeight: FontWeight.w700)), + ), + ], + ), + ); + } +} + +/// Kick off the update: in-app OTA on Android, browser download elsewhere. +Future _startUpdate(BuildContext context, UpdateInfo update) async { + final url = update.apkUrl; + if (url == null || url.isEmpty) return; + if (!UpdateService.supported) { + await UpdateService.openInBrowser(url); + return; + } + await showDialog( + context: context, + barrierDismissible: true, + builder: (_) => _UpdateDialog(update: update), + ); +} + +class _UpdateDialog extends StatefulWidget { + final UpdateInfo update; + const _UpdateDialog({required this.update}); + @override + State<_UpdateDialog> createState() => _UpdateDialogState(); +} + +class _UpdateDialogState extends State<_UpdateDialog> { + bool _running = false; + int _percent = 0; + String _phase = ''; + String? _error; + + void _run() { + setState(() { + _running = true; + _error = null; + _phase = 'downloading'; + }); + UpdateService.install(widget.update.apkUrl!).listen( + (p) { + if (!mounted) return; + if (p.phase == 'error') { + setState(() => _error = p.message ?? 'Update failed'); + } else { + setState(() { + _phase = p.phase; + _percent = p.percent; + }); + } + }, + onError: (e) { + if (mounted) setState(() => _error = '$e'); + }, + ); + } + + @override + Widget build(BuildContext context) { + final u = widget.update; + final ver = u.latestVersion != null ? 'v${u.latestVersion}' : 'New version'; + return AlertDialog( + backgroundColor: AppColors.surface, + title: Text(ver, style: AppText.h2), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!_running && (u.notes?.isNotEmpty ?? false)) + Text(u.notes!, style: AppText.bodySoft) + else if (!_running) + Text("Download and install the latest OpenStrap.", style: AppText.bodySoft), + if (_running) ...[ + const SizedBox(height: Sp.x2), + Text( + _error != null + ? 'Could not install automatically.' + : _phase == 'installing' + ? 'Opening installer…' + : 'Downloading… $_percent%', + style: AppText.bodySoft, + ), + const SizedBox(height: Sp.x3), + if (_error == null) + ClipRRect( + borderRadius: BorderRadius.circular(R.pill), + child: LinearProgressIndicator( + value: _phase == 'installing' ? null : (_percent / 100).clamp(0.0, 1.0), + minHeight: 6, + backgroundColor: AppColors.coralSoft, + color: AppColors.coralDeep, + ), + ), + if (_error != null) ...[ + const SizedBox(height: Sp.x2), + Text('Tip: allow "install unknown apps" for OpenStrap, or download in your browser.', + style: AppText.captionMuted), + ], + ], + ], + ), + actions: [ + if (_error != null) + TextButton( + onPressed: () => UpdateService.openInBrowser(widget.update.apkUrl!), + child: Text('Open in browser', style: AppText.label.copyWith(color: AppColors.coralDeep)), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text('Close', style: AppText.label.copyWith(color: AppColors.inkMuted)), + ), + if (!_running || _error != null) + FilledButton( + style: FilledButton.styleFrom(backgroundColor: AppColors.coralDeep), + onPressed: _run, + child: Text(_error != null ? 'Retry' : 'Update now'), + ), + ], + ); + } +} + +// ── admin alert banner ────────────────────────────────────────────────────── +class _AlertCard extends StatelessWidget { + final BannerInfo banner; + final VoidCallback onDismiss; + const _AlertCard({required this.banner, required this.onDismiss}); + + ({Color bg, Color fg, IconData icon}) get _style { + switch (banner.level) { + case BannerLevel.critical: + return (bg: AppColors.warnSoft, fg: AppColors.coralDeep, icon: Ic.shield); + case BannerLevel.warn: + return (bg: AppColors.warnSoft, fg: AppColors.warn, icon: Ic.bell); + case BannerLevel.info: + return (bg: AppColors.coralSoft, fg: AppColors.coralDeep, icon: Ic.info); + } + } + + bool get _hasLink => banner.actionUrl?.isNotEmpty ?? false; + + @override + Widget build(BuildContext context) { + final s = _style; + // Link given → whole card is tappable and opens it. No link → not tappable. + return ProCard( + color: s.bg, + onTap: _hasLink ? () => UpdateService.openInBrowser(banner.actionUrl!) : null, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(Sp.x3), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(R.chip), + ), + child: AppIcon(s.icon, size: 20, color: s.fg), + ), + const SizedBox(width: Sp.x4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (banner.title?.isNotEmpty ?? false) ...[ + Text(banner.title!, style: AppText.title), + const SizedBox(height: Sp.x1), + ], + if (banner.text.isNotEmpty) Text(banner.text, style: AppText.bodySoft), + // Tappable affordance — only when a link is attached. + if (_hasLink) ...[ + const SizedBox(height: Sp.x2), + Row(mainAxisSize: MainAxisSize.min, children: [ + Text('Open', style: AppText.label.copyWith(color: s.fg, fontWeight: FontWeight.w700)), + const SizedBox(width: 3), + AppIcon(Ic.arrowRight, size: 14, color: s.fg), + ]), + ], + ], + ), + ), + if (banner.dismissible) + GestureDetector( + onTap: onDismiss, + behavior: HitTestBehavior.opaque, + child: const Padding( + padding: EdgeInsets.only(left: Sp.x2), + child: AppIcon(Ic.cancel, size: 18, color: AppColors.inkMuted), + ), + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 1eb6619..a20c826 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -496,6 +496,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.4.1" + ota_update: + dependency: "direct main" + description: + name: ota_update + sha256: "8c47531b655f8fcf9961dc7758a933a9ccbcbe1c77a7ec52e5c5cfc2ec367ba7" + url: "https://pub.dev" + source: hosted + version: "6.0.0" package_config: dependency: transitive description: @@ -504,6 +512,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: "direct main" description: @@ -877,6 +901,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c" + url: "https://pub.dev" + source: hosted + version: "6.3.30" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" url_launcher_linux: dependency: transitive description: @@ -885,6 +933,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" url_launcher_platform_interface: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index bbebbfb..63b5904 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: openstrap_edge description: "OpenStrap — open-source WHOOP 4.0 companion. BLE drain → raw-first local store → cloud sync." publish_to: 'none' -version: 0.2.0+3 +version: 0.3.0+4 environment: sdk: ^3.11.4 @@ -39,6 +39,12 @@ dependencies: # Home/lock-screen widget bridge (writes a snapshot to the App Group → WidgetKit). home_widget: ^0.6.0 + # OTA self-update (Android sideload): read our own version, download + install the + # signed APK from the backend's update pointer, with a browser fallback. + package_info_plus: ^8.1.0 + ota_update: ^6.0.0 + url_launcher: ^6.3.0 + dev_dependencies: flutter_test: sdk: flutter From 6836d7b1516badd0bb097031b185ea40c7e75065 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 14 Jun 2026 19:09:21 +0530 Subject: [PATCH 20/55] =?UTF-8?q?edge:=20fix=20release=20build=20for=20OTA?= =?UTF-8?q?=20=E2=80=94=20ota=5Fupdate=207.x=20+=20core=20library=20desuga?= =?UTF-8?q?ring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.3.0's release build failed at ota_update:verifyReleaseResources (android:attr/lStar not found — plugin compiled against an old SDK) and then checkReleaseAarMetadata (desugaring required). Bump ota_update 6→7.1.0 (modern compileSdk) and enable isCoreLibraryDesugaringEnabled + desugar_jdk_libs. Debug build hid this — verifyReleaseResources is release-only. Verified: flutter build apk --release succeeds locally (58MB). --- android/app/build.gradle.kts | 7 +++++++ pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 72959aa..f390c67 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -27,6 +27,8 @@ android { ndkVersion = flutter.ndkVersion compileOptions { + // Required by ota_update 7.x (desugars java.time/java.nio APIs on older API levels). + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -73,3 +75,8 @@ android { flutter { source = "../.." } + +dependencies { + // Backs isCoreLibraryDesugaringEnabled (required by ota_update 7.x). + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} diff --git a/pubspec.lock b/pubspec.lock index a20c826..19014c7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -500,10 +500,10 @@ packages: dependency: "direct main" description: name: ota_update - sha256: "8c47531b655f8fcf9961dc7758a933a9ccbcbe1c77a7ec52e5c5cfc2ec367ba7" + sha256: "1f4c7c3c4f306729a6c00b84435096ce2d8b28439013f7237173acc699b2abc8" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "7.1.0" package_config: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 63b5904..cb6e093 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,7 +42,7 @@ dependencies: # OTA self-update (Android sideload): read our own version, download + install the # signed APK from the backend's update pointer, with a browser fallback. package_info_plus: ^8.1.0 - ota_update: ^6.0.0 + ota_update: ^7.1.0 url_launcher: ^6.3.0 dev_dependencies: From 8c890b37fb1bd959093be942b6bbc8d2e8f33d2a Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 15 Jun 2026 21:11:16 +0530 Subject: [PATCH 21/55] feat: sleep v2 UI, step goal, minute-detail retention gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Step goal: setting screen (progress ring) + Today ring entry + profile row (PATCH /profile) - Sleep v2: per-period sleep screen (naps as cards) + /sleep/v2 client methods - Minute-detail gating: hypnogram / 24h HR / timeline / wear / stress / workout-HR show for the last 7 days, daily summary after (Body/strain curve kept — stored) - kit: detailedAvailable() + DetailRetentionNote helper --- ios/Podfile.lock | 12 ++ lib/models/payloads.dart | 11 +- lib/net/api_client.dart | 18 ++ lib/ui/journey/journey_screen.dart | 16 +- lib/ui/kit/kit.dart | 37 ++++ lib/ui/profile/profile_screen.dart | 24 +++ lib/ui/screens/detail_cards.dart | 15 +- lib/ui/sleep/sleep_detail_screen.dart | 13 +- lib/ui/sleep/sleep_periods_screen.dart | 284 +++++++++++++++++++++++++ lib/ui/stress/stress_screen.dart | 9 +- lib/ui/today/step_goal_screen.dart | 233 ++++++++++++++++++++ lib/ui/today/today_screen.dart | 5 + lib/ui/workouts/workouts_screen.dart | 12 +- 13 files changed, 674 insertions(+), 15 deletions(-) create mode 100644 lib/ui/sleep/sleep_periods_screen.dart create mode 100644 lib/ui/today/step_goal_screen.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock index ae8bfe2..6bf2f11 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -5,6 +5,8 @@ PODS: - FlutterMacOS - home_widget (0.0.1): - Flutter + - package_info_plus (0.4.5): + - Flutter - share_plus (0.0.1): - Flutter - shared_preferences_foundation (0.0.1): @@ -13,14 +15,18 @@ PODS: - sqflite_darwin (0.0.4): - Flutter - FlutterMacOS + - url_launcher_ios (0.0.1): + - Flutter DEPENDENCIES: - Flutter (from `Flutter`) - flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`) - home_widget (from `.symlinks/plugins/home_widget/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) EXTERNAL SOURCES: Flutter: @@ -29,20 +35,26 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin" home_widget: :path: ".symlinks/plugins/home_widget/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" share_plus: :path: ".symlinks/plugins/share_plus/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" sqflite_darwin: :path: ".symlinks/plugins/sqflite_darwin/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b PODFILE CHECKSUM: d356a3c7ef9fe1586fd8829c4e39f9dee09401b4 diff --git a/lib/models/payloads.dart b/lib/models/payloads.dart index 5d3996a..dec8bb9 100644 --- a/lib/models/payloads.dart +++ b/lib/models/payloads.dart @@ -41,8 +41,13 @@ class TodayData { final Map? _hrv; final Map? _skinTemp; final Map? _spo2; + + /// User's daily step goal (null → use the client default). Top-level on /today. + final int? stepGoal; + TodayData._(this._daily, this._sleep, this._coach, this._stress, - this._nocturnal, this._resp, this._hrv, this._skinTemp, this._spo2); + this._nocturnal, this._resp, this._hrv, this._skinTemp, this._spo2, + this.stepGoal); factory TodayData.fromJson(Object? json) { final row = json is Map ? json.cast() : const {}; @@ -50,8 +55,10 @@ class TodayData { (row[k] is Map) ? (row[k] as Map).cast() : null; final daily = sub('daily') ?? const {}; final sleep = sub('sleep') ?? const {}; + final goal = (row['step_goal'] as num?)?.toInt(); return TodayData._(daily, sleep, sub('coach'), sub('stress'), - sub('nocturnal'), sub('resp'), sub('hrv'), sub('skin_temp'), sub('spo2')); + sub('nocturnal'), sub('resp'), sub('hrv'), sub('skin_temp'), sub('spo2'), + goal); } /// Nocturnal HRV (RMSSD, ms) — measured from beat-to-beat intervals. Null until diff --git a/lib/net/api_client.dart b/lib/net/api_client.dart index cac303e..a9875ad 100644 --- a/lib/net/api_client.dart +++ b/lib/net/api_client.dart @@ -185,6 +185,24 @@ class ApiClient { Future>> getSleep({int? from, int? to}) => _getList('/sleep', _range(from, to)); + /// GET /sleep/v2?from&to (date strings) → { days:[{date, periods[], + /// total_asleep_min, period_count}], need_min }. Multi-period (naps = shorter + /// sleeps). Additive companion to getSleep; old /sleep is untouched. + Future> getSleepV2({String? from, String? to}) => + _getObj('/sleep/v2', { + if (from != null) 'from': from, + if (to != null) 'to': to, + }); + + /// GET /day/v2/sleep?date= → { date, has_sleep, need_min, total_asleep_min, + /// periods:[{onset_ts, wake_ts, duration_min, efficiency, stages, is_main, …}] }. + Future> getDaySleepV2(String date) => + _getObj('/day/v2/sleep', {'date': date}); + + /// PATCH /profile { step_goal } → updated user. Convenience for the goal screen. + Future> setStepGoal(int goal) => + patchProfile({'step_goal': goal}); + /// GET /strain?from&to → list of daily rows (newest first). Future>> getStrain({int? from, int? to}) => _getList('/strain', _range(from, to)); diff --git a/lib/ui/journey/journey_screen.dart b/lib/ui/journey/journey_screen.dart index 6d90f6a..4a1e247 100644 --- a/lib/ui/journey/journey_screen.dart +++ b/lib/ui/journey/journey_screen.dart @@ -210,14 +210,20 @@ class _JourneyScreenState extends State { } List _story() { + final detailed = detailedAvailable(widget.date); return [ _highsStrip(), const SizedBox(height: Sp.x6), - const SectionHeader('The timeline'), - _timelineCard(), - const SizedBox(height: Sp.x6), - const SectionHeader('Movement'), - _movementCard(), + // Minute-level 24h timeline + movement only for recent days; workouts and + // events below come from permanent tables and always show. + if (detailed) ...[ + const SectionHeader('The timeline'), + _timelineCard(), + const SizedBox(height: Sp.x6), + const SectionHeader('Movement'), + _movementCard(), + ] else + const DetailRetentionNote(what: '24-hour timeline'), ..._workoutsSection(), ..._eventsSection(), ]; diff --git a/lib/ui/kit/kit.dart b/lib/ui/kit/kit.dart index 2da6892..936467f 100644 --- a/lib/ui/kit/kit.dart +++ b/lib/ui/kit/kit.dart @@ -417,3 +417,40 @@ class DetailRow extends StatelessWidget { /// Empty/placeholder line shown when a metric has no confident value. Widget metricDash([double size = 30]) => Text('—', style: AppText.metric.copyWith(color: AppColors.inkMuted, fontSize: size)); + +/// Minute-level detail (hypnogram, 24h timelines, wear histogram) is retained for +/// this many days; older days show the daily summary only. Keep in sync with the +/// backend's minute-table retention. +const int kDetailWindowDays = 7; + +/// True when a 'YYYY-MM-DD' date is recent enough to still have minute-level detail. +bool detailedAvailable(String ymd) { + final p = ymd.split('-'); + if (p.length != 3) return true; + final y = int.tryParse(p[0]), m = int.tryParse(p[1]), d = int.tryParse(p[2]); + if (y == null || m == null || d == null) return true; + final date = DateTime.utc(y, m, d); + final now = DateTime.now().toUtc(); + final today = DateTime.utc(now.year, now.month, now.day); + return today.difference(date).inDays <= kDetailWindowDays; +} + +/// Shown in place of a minute-level chart for dates older than the detail window. +class DetailRetentionNote extends StatelessWidget { + final String what; // e.g. 'hypnogram', 'minute-by-minute heart rate' + const DetailRetentionNote({super.key, this.what = 'minute-by-minute detail'}); + @override + Widget build(BuildContext context) => ProCard( + child: Row(children: [ + const AppIcon(Ic.clock, size: 20, color: AppColors.inkMuted), + const SizedBox(width: Sp.x3), + Expanded( + child: Text( + 'Detailed $what is kept for the last $kDetailWindowDays days. ' + 'For older dates we show your daily summary.', + style: AppText.caption.copyWith(color: AppColors.inkSoft), + ), + ), + ]), + ); +} diff --git a/lib/ui/profile/profile_screen.dart b/lib/ui/profile/profile_screen.dart index 5037178..a50008d 100644 --- a/lib/ui/profile/profile_screen.dart +++ b/lib/ui/profile/profile_screen.dart @@ -8,6 +8,7 @@ import '../../state/app_state.dart'; import '../../theme/theme.dart'; import '../../theme/tokens.dart'; import '../kit/kit.dart'; +import '../today/step_goal_screen.dart'; class ProfileScreen extends StatelessWidget { const ProfileScreen({super.key}); @@ -99,6 +100,29 @@ class ProfileScreen extends StatelessWidget { const SizedBox(height: Sp.x7), + // ── Goals ──────────────────────────────────────────────────── + const SectionHeader('Goals'), + ProCard( + padding: const EdgeInsets.symmetric( + horizontal: Sp.x5, vertical: Sp.x2), + child: DetailRow( + icon: Ic.run, + label: 'Daily step goal', + value: user['step_goal'] != null + ? '${user['step_goal']} steps' + : 'Set', + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => StepGoalScreen( + goal: (user['step_goal'] as num?)?.toInt(), + ), + ), + ), + ), + ), + + const SizedBox(height: Sp.x7), + // ── Backend ────────────────────────────────────────────────── const SectionHeader('Backend'), ProCard( diff --git a/lib/ui/screens/detail_cards.dart b/lib/ui/screens/detail_cards.dart index 599797e..82dd411 100644 --- a/lib/ui/screens/detail_cards.dart +++ b/lib/ui/screens/detail_cards.dart @@ -132,7 +132,9 @@ class HeartDayCard extends StatelessWidget { ]), ), - if (hr.length > 1) ...[ + // Minute-level 24h HR only for recent days; recovery/HRV/zones below are + // permanent summaries and always show. + if (detailedAvailable(date) && hr.length > 1) ...[ const SizedBox(height: Sp.x6), SectionHeader('Heart rate'), ProCard(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -140,6 +142,9 @@ class HeartDayCard extends StatelessWidget { const SizedBox(height: Sp.x3), Text('avg ${d['avg_hr'] ?? '—'} · max ${d['max_hr'] ?? '—'} bpm', style: AppText.captionMuted), ])), + ] else if (!detailedAvailable(date)) ...[ + const SizedBox(height: Sp.x6), + const DetailRetentionNote(what: 'minute-by-minute heart rate'), ], // HRV — full Task-Force suite, each tappable into its trend. @@ -437,8 +442,12 @@ class WearDayCard extends StatelessWidget { ]), ), - // 24-hour coverage strip. - if (hourly.length == 24) ...[ + // 24-hour coverage strip — minute-level, recent days only. The worn-time + // total + first/last/segments summary above is permanent. + if (!detailedAvailable(date)) ...[ + const SizedBox(height: Sp.x6), + const DetailRetentionNote(what: 'hourly wear breakdown'), + ] else if (hourly.length == 24) ...[ const SizedBox(height: Sp.x6), SectionHeader('Hourly coverage'), ProCard(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/ui/sleep/sleep_detail_screen.dart b/lib/ui/sleep/sleep_detail_screen.dart index 19de943..a929dc1 100644 --- a/lib/ui/sleep/sleep_detail_screen.dart +++ b/lib/ui/sleep/sleep_detail_screen.dart @@ -12,6 +12,7 @@ import '../kit/kit.dart'; import '../kit/charts.dart'; import '../screens/metric_row.dart'; import '../screens/trend_screen.dart'; +import 'sleep_periods_screen.dart'; class SleepDetailScreen extends StatefulWidget { final String date; // 'YYYY-MM-DD' @@ -249,7 +250,11 @@ class _SleepDetailScreenState extends State { return [ _hero(), const SizedBox(height: Sp.x6), - _hypnogramCard(), + // Minute-level hypnogram only for recent nights; older nights keep the + // stage/efficiency/debt summaries below (those are permanent). + detailedAvailable(widget.date) + ? _hypnogramCard() + : const DetailRetentionNote(what: 'sleep hypnogram'), const SizedBox(height: Sp.x6), SectionHeader('Stages'), _stageBreakdown(), @@ -333,6 +338,12 @@ class _SleepDetailScreenState extends State { ], ), ), + // All sleeps of the day (naps included) — the v2 multi-period view. + RoundIconButton(Ic.bed, onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SleepPeriodsScreen(date: widget.date), + ), + )), ], ); } diff --git a/lib/ui/sleep/sleep_periods_screen.dart b/lib/ui/sleep/sleep_periods_screen.dart new file mode 100644 index 0000000..f435a39 --- /dev/null +++ b/lib/ui/sleep/sleep_periods_screen.dart @@ -0,0 +1,284 @@ +// Sleep periods (v2) — every sleep of the day, one card each. A nap is not a +// special case: it's just a shorter sleep. Slept once → one card; napped twice → +// three cards. Backed by /day/v2/sleep (additive; the single-night /day/sleep +// screen is untouched). "Ember on Paper" design. + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../net/api_client.dart'; +import '../../state/app_state.dart'; +import '../../theme/theme.dart'; +import '../../theme/tokens.dart'; +import '../kit/kit.dart'; + +class SleepPeriodsScreen extends StatefulWidget { + final String date; // 'YYYY-MM-DD' + const SleepPeriodsScreen({super.key, required this.date}); + + @override + State createState() => _SleepPeriodsScreenState(); +} + +enum _Phase { loading, ready, empty, error } + +class _SleepPeriodsScreenState extends State { + _Phase _phase = _Phase.loading; + String? _error; + Map _data = const {}; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final api = context.read().api; + if (api == null) { + setState(() { _phase = _Phase.error; _error = 'Not signed in.'; }); + return; + } + setState(() { _phase = _Phase.loading; _error = null; }); + try { + final res = await api.getDaySleepV2(widget.date); + if (!mounted) return; + setState(() { + _data = res; + _phase = _periods.isEmpty ? _Phase.empty : _Phase.ready; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _phase = _Phase.error; + _error = e is ApiException ? e.body : e.toString(); + }); + } + } + + // ── parsing ────────────────────────────────────────────────────────────── + num? _num(Object? v) => v is num ? v : (v is String ? num.tryParse(v) : null); + List> get _periods { + final p = _data['periods']; + if (p is List) return p.whereType().map((e) => e.cast()).toList(); + return const []; + } + + int get _needMin => (_num(_data['need_min'])?.toInt()) ?? 480; + int get _totalAsleep => (_num(_data['total_asleep_min'])?.toInt()) ?? 0; + bool get _beta => _data['stages_beta'] == true; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.bg, + body: SafeArea( + bottom: false, + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: Sp.screen), + children: [ + const SizedBox(height: Sp.x4), + _topBar(), + const SizedBox(height: Sp.x6), + if (_phase == _Phase.loading) + _stateCard(Ic.moon, 'Loading…', 'Fetching your sleep periods.') + else if (_phase == _Phase.empty) + _stateCard(Ic.bed, 'No sleep detected', + 'Wear your strap overnight (and through any naps) and sync to ' + 'see each sleep here.') + else if (_phase == _Phase.error) + _stateCard(Ic.cloud, "Couldn't load sleep", _error ?? 'Please try again.') + else ...[ + _summary(), + const SizedBox(height: Sp.x6), + for (final p in _periods) ...[ + _periodCard(p), + const SizedBox(height: Sp.x4), + ], + const SizedBox(height: Sp.x2), + Text( + 'Stages are a beta estimate from heart rate + motion (no EEG). A ' + 'nap is scored the same way as a full night — just shorter.', + style: AppText.caption.copyWith(color: AppColors.inkMuted), + ), + ], + const SizedBox(height: 40), + ], + ), + ), + ); + } + + // Day summary: total asleep across all periods vs need. + Widget _summary() { + final n = _periods.length; + return GlowCard( + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Total sleep', style: AppText.label.copyWith(color: AppColors.inkSoft)), + const SizedBox(height: Sp.x2), + Text(_hm(_totalAsleep), style: AppText.metric.copyWith(fontSize: 40)), + const SizedBox(height: 2), + Text('need ${_hm(_needMin)} · $n sleep${n == 1 ? '' : 's'}', + style: AppText.caption.copyWith(color: AppColors.inkSoft)), + ], + ), + ), + const AppIcon(Ic.moon, size: 30, color: AppColors.coral), + ], + ), + ); + } + + Widget _periodCard(Map p) { + final isMain = p['is_main'] == true; + final onset = _num(p['onset_ts'])?.toInt(); + final wake = _num(p['wake_ts'])?.toInt(); + final dur = _num(p['duration_min'])?.toInt() ?? 0; + final eff = _num(p['efficiency'])?.toDouble(); + final conf = _num(p['confidence'])?.toDouble() ?? 0; + final stages = (p['stages'] is Map) ? (p['stages'] as Map).cast() : null; + + return ProCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + AppIcon(isMain ? Ic.moon : Ic.bed, + size: 20, color: isMain ? AppColors.coral : AppColors.inkSoft), + const SizedBox(width: Sp.x2), + Text(isMain ? 'Main sleep' : 'Nap', style: AppText.h2), + const SizedBox(width: Sp.x2), + if (_beta) const Tag('beta', color: AppColors.coral), + const Spacer(), + ConfDot(conf), + ], + ), + const SizedBox(height: Sp.x3), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(_hm(dur), style: AppText.metric.copyWith(fontSize: 32)), + const SizedBox(width: Sp.x3), + if (eff != null) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Text('${(eff * 100).round()}% efficiency', + style: AppText.caption.copyWith(color: AppColors.inkSoft)), + ), + ], + ), + if (onset != null && wake != null) ...[ + const SizedBox(height: 2), + Text('${_clock(onset)} – ${_clock(wake)}', + style: AppText.label.copyWith(color: AppColors.inkSoft)), + ], + if (stages != null) ...[ + const SizedBox(height: Sp.x4), + _stageBar(stages), + const SizedBox(height: Sp.x3), + _stageLegend(stages), + ], + ], + ), + ); + } + + Widget _stageBar(Map s) { + final deep = (_num(s['deep_min']) ?? 0).toDouble(); + final rem = (_num(s['rem_min']) ?? 0).toDouble(); + final light = (_num(s['light_min']) ?? 0).toDouble(); + final total = deep + rem + light; + if (total <= 0) return const SizedBox.shrink(); + Widget seg(double v, Color c) => + v <= 0 ? const SizedBox.shrink() : Expanded(flex: (v * 100).round(), child: Container(color: c)); + return ClipRRect( + borderRadius: BorderRadius.circular(R.chip), + child: SizedBox( + height: 14, + child: Row(children: [ + seg(deep, _stageColor('deep')), + seg(light, _stageColor('light')), + seg(rem, _stageColor('rem')), + ]), + ), + ); + } + + Widget _stageLegend(Map s) { + Widget item(String label, String key, Color c) { + final v = _num(s['${key}_min'])?.toInt() ?? 0; + return Row(mainAxisSize: MainAxisSize.min, children: [ + Container(width: 9, height: 9, decoration: BoxDecoration(color: c, shape: BoxShape.circle)), + const SizedBox(width: 5), + Text('$label ${_hm(v)}', style: AppText.caption.copyWith(color: AppColors.inkSoft)), + ]); + } + return Wrap(spacing: Sp.x4, runSpacing: Sp.x2, children: [ + item('Deep', 'deep', _stageColor('deep')), + item('Light', 'light', _stageColor('light')), + item('REM', 'rem', _stageColor('rem')), + ]); + } + + // Same stage palette as the single-night detail screen. + Color _stageColor(String stage) { + switch (stage) { + case 'light': + return AppColors.coral.withValues(alpha: 0.35); + case 'rem': + return AppColors.coral; + case 'deep': + return AppColors.coralDeep; + default: + return AppColors.inkMuted; + } + } + + String _hm(int minutes) { + final h = minutes ~/ 60; + final m = minutes % 60; + if (h <= 0) return '${m}m'; + return '${h}h ${m}m'; + } + + String _clock(int epochSec) { + final d = DateTime.fromMillisecondsSinceEpoch(epochSec * 1000); + final h24 = d.hour; + final ampm = h24 < 12 ? 'AM' : 'PM'; + var h = h24 % 12; + if (h == 0) h = 12; + return '$h:${d.minute.toString().padLeft(2, '0')} $ampm'; + } + + Widget _stateCard(IconData icon, String title, String body) => ProCard( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + AppIcon(icon, size: 26, color: AppColors.inkSoft), + const SizedBox(height: Sp.x3), + Text(title, style: AppText.h2), + const SizedBox(height: Sp.x2), + Text(body, style: AppText.body.copyWith(color: AppColors.inkSoft)), + ]), + ); + + Widget _topBar() => Row(children: [ + RoundIconButton(Ic.arrowLeft, onTap: () => Navigator.of(context).maybePop()), + const SizedBox(width: Sp.x3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('Sleep periods', style: AppText.h1), + Text('Every sleep, naps included', + style: AppText.caption.copyWith(color: AppColors.inkSoft)), + ], + ), + ), + ]); +} diff --git a/lib/ui/stress/stress_screen.dart b/lib/ui/stress/stress_screen.dart index 1398009..490d841 100644 --- a/lib/ui/stress/stress_screen.dart +++ b/lib/ui/stress/stress_screen.dart @@ -163,8 +163,13 @@ class _StressScreenState extends State { else ...[ _hero(), const SizedBox(height: Sp.x6), - const SectionHeader('Across the day'), - _bandCard(), + // Minute-level stress band only for recent days; the score + breakdown + // are daily summaries and always show. + if (detailedAvailable(widget.date)) ...[ + const SectionHeader('Across the day'), + _bandCard(), + ] else + const DetailRetentionNote(what: 'across-the-day stress'), const SizedBox(height: Sp.x6), const SectionHeader('Time in each state'), _breakdownCard(), diff --git a/lib/ui/today/step_goal_screen.dart b/lib/ui/today/step_goal_screen.dart new file mode 100644 index 0000000..79da78a --- /dev/null +++ b/lib/ui/today/step_goal_screen.dart @@ -0,0 +1,233 @@ +// Step goal — set a daily step target and see today's progress against it. +// Steps themselves are the authoritative IMU pedometer count (server-side); this +// screen only sets users.step_goal (PATCH /profile) and renders progress. +// "Ember on Paper" design: warm bg, coral/good accent, big tabular numbers. + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../net/api_client.dart'; +import '../../state/app_state.dart'; +import '../../theme/theme.dart'; +import '../../theme/tokens.dart'; +import '../kit/kit.dart'; +import '../kit/charts.dart'; + +class StepGoalScreen extends StatefulWidget { + /// Today's step count (for the progress ring), if known. + final int? steps; + + /// Current saved goal (null → client default). + final int? goal; + const StepGoalScreen({super.key, this.steps, this.goal}); + + /// Client-side default when the user hasn't set one (mirrors the backend note). + static const int defaultGoal = 8000; + + @override + State createState() => _StepGoalScreenState(); +} + +class _StepGoalScreenState extends State { + static const _presets = [5000, 8000, 10000, 12000, 15000]; + static const _step = 500; + static const _min = 1000; + static const _max = 50000; + + late int _goal; + bool _saving = false; + + @override + void initState() { + super.initState(); + _goal = (widget.goal ?? StepGoalScreen.defaultGoal).clamp(_min, _max); + } + + void _set(int g) => setState(() => _goal = g.clamp(_min, _max)); + + Future _save() async { + final app = context.read(); + if (app.api == null) { + Navigator.of(context).maybePop(); + return; + } + setState(() => _saving = true); + try { + // Routed through AppState so session.user updates and listeners refresh. + await app.updateProfile({'step_goal': _goal}); + if (!mounted) return; + Navigator.of(context).pop(_goal); + } on ApiException catch (e) { + if (!mounted) return; + setState(() => _saving = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Couldn't save goal: ${e.body}")), + ); + } + } + + @override + Widget build(BuildContext context) { + final steps = widget.steps ?? 0; + final t = _goal > 0 ? (steps / _goal).clamp(0.0, 1.0).toDouble() : 0.0; + final reached = steps >= _goal && steps > 0; + final remaining = (_goal - steps).clamp(0, _goal); + + return Scaffold( + backgroundColor: AppColors.bg, + body: SafeArea( + bottom: false, + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: Sp.screen), + children: [ + const SizedBox(height: Sp.x4), + _topBar(), + const SizedBox(height: Sp.x6), + + // Progress ring. + Center( + child: RingStat( + t: t, + color: AppColors.good, + size: 196, + stroke: 16, + center: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('$steps', + style: AppText.metric.copyWith(fontSize: 44)), + const SizedBox(height: 2), + Text('of $_goal steps', + style: AppText.caption + .copyWith(color: AppColors.inkSoft)), + ], + ), + ), + ), + const SizedBox(height: Sp.x5), + Center( + child: reached + ? const Tag('goal reached', color: AppColors.good) + : Text('$remaining to go', + style: AppText.label.copyWith(color: AppColors.inkSoft)), + ), + const SizedBox(height: Sp.x7), + + const SectionHeader('Daily goal'), + ProCard( + child: Column( + children: [ + // Stepper row. + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + RoundIconButton(Ic.down, + bg: AppColors.surfaceAlt, + onTap: _saving ? null : () => _set(_goal - _step)), + Column( + children: [ + Text('$_goal', style: AppText.metric.copyWith(fontSize: 36)), + Text('steps / day', + style: AppText.caption + .copyWith(color: AppColors.inkSoft)), + ], + ), + RoundIconButton(Ic.up, + bg: AppColors.surfaceAlt, + onTap: _saving ? null : () => _set(_goal + _step)), + ], + ), + const SizedBox(height: Sp.x4), + // Presets. + Wrap( + spacing: Sp.x2, + runSpacing: Sp.x2, + alignment: WrapAlignment.center, + children: [ + for (final p in _presets) _presetChip(p), + ], + ), + ], + ), + ), + const SizedBox(height: Sp.x6), + + // Save. + _saveButton(), + const SizedBox(height: Sp.x4), + Text( + 'Steps are counted on-device from motion and finalized server-side. ' + 'The goal is just a target — change it any time.', + style: AppText.caption.copyWith(color: AppColors.inkMuted), + ), + const SizedBox(height: 40), + ], + ), + ), + ); + } + + Widget _presetChip(int p) { + final sel = p == _goal; + return GestureDetector( + onTap: _saving ? null : () => _set(p), + child: AnimatedContainer( + duration: Motion.fast, + padding: const EdgeInsets.symmetric(horizontal: Sp.x4, vertical: Sp.x2), + decoration: BoxDecoration( + color: sel ? AppColors.ink : AppColors.surfaceAlt, + borderRadius: BorderRadius.circular(R.pill), + ), + child: Text( + '${(p / 1000).toStringAsFixed(p % 1000 == 0 ? 0 : 1)}k', + style: AppText.label.copyWith( + color: sel ? AppColors.onNight : AppColors.inkSoft, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } + + Widget _saveButton() { + return Material( + color: AppColors.coral, + borderRadius: BorderRadius.circular(R.pill), + child: InkWell( + borderRadius: BorderRadius.circular(R.pill), + onTap: _saving ? null : _save, + child: Container( + height: 54, + alignment: Alignment.center, + child: _saving + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.4, color: AppColors.onNight), + ) + : Text('Save goal', + style: AppText.label.copyWith( + color: AppColors.onNight, fontWeight: FontWeight.w700)), + ), + ), + ); + } + + Widget _topBar() => Row(children: [ + RoundIconButton(Ic.arrowLeft, + onTap: () => Navigator.of(context).maybePop()), + const SizedBox(width: Sp.x3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('Step goal', style: AppText.h1), + Text('Set your daily target', + style: AppText.caption.copyWith(color: AppColors.inkSoft)), + ], + ), + ), + ]); +} diff --git a/lib/ui/today/today_screen.dart b/lib/ui/today/today_screen.dart index ed00cb5..6a7c310 100644 --- a/lib/ui/today/today_screen.dart +++ b/lib/ui/today/today_screen.dart @@ -13,6 +13,7 @@ import '../kit/kit.dart'; import '../kit/charts.dart'; import '../widgets/screen_loader.dart'; import '../widgets/status_banner.dart'; +import 'step_goal_screen.dart'; import '../journal/journal_screen.dart'; import '../recap/recap_screen.dart'; import '../coach/coach_screen.dart'; @@ -301,6 +302,10 @@ class _TodayScreenState extends State accent: AppColors.good, confidence: t.steps.isEmpty ? null : t.steps.confidence, tag: Tag.forMetric(t.steps), + onTap: () => _push(StepGoalScreen( + steps: t.steps.isEmpty ? null : t.steps.value!.round(), + goal: t.stepGoal, + )), ), StatTile( icon: Ic.watch, diff --git a/lib/ui/workouts/workouts_screen.dart b/lib/ui/workouts/workouts_screen.dart index 895164f..9ea6b6a 100644 --- a/lib/ui/workouts/workouts_screen.dart +++ b/lib/ui/workouts/workouts_screen.dart @@ -451,6 +451,11 @@ class _WorkoutDetailBodyState extends State<_WorkoutDetailBody> { final strain = _n(d['strain']); final drift = _n(d['hr_drift_pct']); final ttp = _n(d['time_to_peak_min']); + // Minute-level HR curve only for recent workouts; the summary (avg/max/zones/ + // strain) is permanent in the sessions table and always shows. + final startTs = d['start_ts'] as int?; + final workoutRecent = startTs == null || + startTs > (DateTime.now().millisecondsSinceEpoch ~/ 1000) - kDetailWindowDays * 86400; return ListView(padding: const EdgeInsets.fromLTRB(Sp.x4, Sp.x4, Sp.x4, Sp.x10), children: [ // ── HERO ── @@ -496,8 +501,11 @@ class _WorkoutDetailBodyState extends State<_WorkoutDetailBody> { ]), ), - // ── HEART RATE ── - if (hr.length > 1) ...[ + // ── HEART RATE ── (minute curve, recent workouts only) + if (!workoutRecent) ...[ + const SizedBox(height: Sp.x4), + const DetailRetentionNote(what: 'minute-by-minute heart rate'), + ] else if (hr.length > 1) ...[ const SizedBox(height: Sp.x4), ProCard(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Heart rate', style: AppText.label), From 526177d7f80f4ba07ca5e4885ca16e2f045ec0b9 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 15 Jun 2026 23:00:04 +0530 Subject: [PATCH 22/55] =?UTF-8?q?chore:=20bump=20to=200.4.0+5=20=E2=80=94?= =?UTF-8?q?=20sleep=20v2,=20step=20goal,=20minute-detail=20retention=20gat?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index cb6e093..d032d33 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: openstrap_edge description: "OpenStrap — open-source WHOOP 4.0 companion. BLE drain → raw-first local store → cloud sync." publish_to: 'none' -version: 0.3.0+4 +version: 0.4.0+5 environment: sdk: ^3.11.4 From 6e3b668da708efb1fc8602126f16fa8874238ef8 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 15 Jun 2026 23:46:20 +0530 Subject: [PATCH 23/55] =?UTF-8?q?fix:=20declare=20ota=5Fupdate=20FileProvi?= =?UTF-8?q?der=20in=20AndroidManifest=20=E2=80=94=20OTA=20crashed=20at=201?= =?UTF-8?q?00%=20download=20(Couldn't=20find=20meta-data=20for=20provider?= =?UTF-8?q?=20...ota=5Fupdate=5Fprovider);=20ota=5Fupdate=207.x=20needs=20?= =?UTF-8?q?the=20host=20app=20to=20declare=20it.=20Bump=200.4.1+6.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 21 +++++++++++++++++++++ android/app/src/main/res/xml/filepaths.xml | 5 +++++ pubspec.yaml | 2 +- 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/res/xml/filepaths.xml diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index dc43bed..dd70ae7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -49,6 +49,27 @@ android:exported="false" android:foregroundServiceType="connectedDevice" /> + + + + + + + + + + + + + + diff --git a/pubspec.yaml b/pubspec.yaml index d032d33..315e19c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: openstrap_edge description: "OpenStrap — open-source WHOOP 4.0 companion. BLE drain → raw-first local store → cloud sync." publish_to: 'none' -version: 0.4.0+5 +version: 0.4.1+6 environment: sdk: ^3.11.4 From 91c9c835e3b8c86ed8e74bb8ca8d5259cda387d3 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Tue, 16 Jun 2026 01:25:27 +0530 Subject: [PATCH 24/55] Add dark mode ("Ember on Char") with smooth, full-app theme switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a System/Light/Dark appearance system that preserves the warm "Ember on Paper" identity — the paper inverts to warm charcoal, the coral ember stays constant. Never cold black. - Palette/AppColors.active token system: light + dark resolved at runtime, all existing AppColors.x call sites unchanged; dark elevation via border. - ThemeController: System/Light/Dark, persisted, follows OS by default so login/signup already match the phone; status-bar icons flip. - Foolproof rebuilds: home stack + all tabs (incl. kept-alive) and every pushed route (themedRoute) re-colour live; cross-fade dissolve transition. - Appearance picker in onboarding (with sex/age) and Profile. - Fixes SegToggle selected-label contrast in dark. - iOS widget + Live Activity follow the app's mode via an App Group flag. Bump 0.4.2+7. --- ios/OpenStrapWidget/OpenStrapWidget.swift | 39 ++- .../OpenStrapWidgetLiveActivity.swift | 55 +++-- lib/app.dart | 59 +++-- lib/main.dart | 11 +- lib/theme/theme.dart | 83 ++++--- lib/theme/theme_controller.dart | 121 +++++++++ lib/theme/theme_switcher.dart | 121 +++++++++ lib/theme/tokens.dart | 231 ++++++++++++++---- lib/ui/activity/activity_screen.dart | 24 +- lib/ui/activity/live_session_screen.dart | 7 +- lib/ui/activity/strain_detail_screen.dart | 20 +- lib/ui/coach/coach_screen.dart | 10 +- lib/ui/journal/journal_screen.dart | 12 +- lib/ui/journey/journey_screen.dart | 14 +- lib/ui/kit/charts.dart | 31 ++- lib/ui/kit/kit.dart | 158 +++++++++--- .../notifications/notifications_screen.dart | 10 +- lib/ui/onboarding_screens.dart | 23 +- lib/ui/pairing_screen.dart | 10 +- lib/ui/profile/profile_screen.dart | 26 +- lib/ui/recap/recap_screen.dart | 10 +- lib/ui/records/records_screen.dart | 6 +- lib/ui/screens/detail_cards.dart | 14 +- lib/ui/screens/metric_row.dart | 9 +- lib/ui/screens/metric_screen.dart | 4 +- lib/ui/screens/trend_screen.dart | 35 +-- lib/ui/sleep/sleep_detail_screen.dart | 22 +- lib/ui/sleep/sleep_periods_screen.dart | 4 +- lib/ui/stress/stress_screen.dart | 16 +- lib/ui/today/step_goal_screen.dart | 2 +- lib/ui/today/today_screen.dart | 65 ++--- lib/ui/widgets/status_banner.dart | 10 +- lib/ui/workouts/workouts_screen.dart | 35 ++- lib/widget/widget_service.dart | 12 + pubspec.yaml | 2 +- 35 files changed, 957 insertions(+), 354 deletions(-) create mode 100644 lib/theme/theme_controller.dart create mode 100644 lib/theme/theme_switcher.dart diff --git a/ios/OpenStrapWidget/OpenStrapWidget.swift b/ios/OpenStrapWidget/OpenStrapWidget.swift index 407819d..6ddc63c 100644 --- a/ios/OpenStrapWidget/OpenStrapWidget.swift +++ b/ios/OpenStrapWidget/OpenStrapWidget.swift @@ -16,17 +16,38 @@ import SwiftUI private let kAppGroup = "group.wtf.openstrap" -// MARK: - Theme (Ember on Paper) +// MARK: - Theme (Ember on Paper / Char) +// The app writes "theme_dark" into the App Group to mirror its in-app appearance +// (which already resolves "System" to the actual OS brightness). Surfaces + ink +// flip between paper and char; the ember coral + ring accents stay constant. private extension Color { - static let paper = Color(red: 244/255, green: 241/255, blue: 236/255) - static let ink = Color(red: 26/255, green: 23/255, blue: 20/255) - static let inkMuted = Color(red: 165/255, green: 156/255, blue: 144/255) - static let surfaceAlt = Color(red: 236/255, green: 231/255, blue: 223/255) - static let coral = Color(red: 255/255, green: 90/255, blue: 54/255) - static let coralDeep = Color(red: 232/255, green: 67/255, blue: 31/255) - static let good = Color(red: 43/255, green: 182/255, blue: 115/255) - static let sleepBlue = Color(red: 124/255, green: 168/255, blue: 240/255) + init(_ r: Int, _ g: Int, _ b: Int) { + self.init(red: Double(r) / 255, green: Double(g) / 255, blue: Double(b) / 255) + } +} + +private struct Pal { + let bg: Color, ink: Color, inkMuted: Color, track: Color + static let light = Pal(bg: Color(244, 241, 236), ink: Color(26, 23, 20), + inkMuted: Color(165, 156, 144), track: Color(236, 231, 223)) + static let dark = Pal(bg: Color(30, 26, 21), ink: Color(241, 236, 227), + inkMuted: Color(126, 116, 102), track: Color(42, 37, 31)) + static var isDark: Bool { + UserDefaults(suiteName: kAppGroup)?.object(forKey: "theme_dark") as? Bool ?? false + } + static var current: Pal { isDark ? .dark : .light } +} + +private extension Color { + static var paper: Color { Pal.current.bg } + static var ink: Color { Pal.current.ink } + static var inkMuted: Color { Pal.current.inkMuted } + static var surfaceAlt: Color { Pal.current.track } + static let coral = Color(255, 90, 54) + static let coralDeep = Color(232, 67, 31) + static let good = Color(43, 182, 115) + static let sleepBlue = Color(124, 168, 240) } // MARK: - Model diff --git a/ios/OpenStrapWidget/OpenStrapWidgetLiveActivity.swift b/ios/OpenStrapWidget/OpenStrapWidgetLiveActivity.swift index 99f4f33..214f9f0 100644 --- a/ios/OpenStrapWidget/OpenStrapWidgetLiveActivity.swift +++ b/ios/OpenStrapWidget/OpenStrapWidgetLiveActivity.swift @@ -33,23 +33,47 @@ struct OpenStrapWidgetAttributes: ActivityAttributes { var targetKcal: Int } -// MARK: - Palette (Ember on Paper, clay) +// MARK: - Palette (Ember on Paper / Char, clay) +// Mirrors the app's in-app appearance via the shared App Group flag "theme_dark" +// (which already accounts for an OS-overriding choice). The clay surface + ink +// flip; the ember coral + zone accents stay constant in both modes. + +private let kAppGroup = "group.wtf.openstrap" + +private extension Color { + init(_ r: Int, _ g: Int, _ b: Int) { + self.init(red: Double(r) / 255, green: Double(g) / 255, blue: Double(b) / 255) + } +} + +private struct Pal { + let clayPaper: Color, claySunk: Color, ink: Color, inkMuted: Color + let isDark: Bool + static let light = Pal(clayPaper: Color(246, 242, 236), claySunk: Color(232, 226, 217), + ink: Color(26, 23, 20), inkMuted: Color(150, 142, 131), isDark: false) + static let dark = Pal(clayPaper: Color(32, 28, 23), claySunk: Color(46, 40, 32), + ink: Color(241, 236, 227), inkMuted: Color(126, 116, 102), isDark: true) + static var current: Pal { + (UserDefaults(suiteName: kAppGroup)?.object(forKey: "theme_dark") as? Bool ?? false) + ? .dark : .light + } +} private extension Color { - static let clayPaper = Color(red: 246/255, green: 242/255, blue: 236/255) - static let claySunk = Color(red: 232/255, green: 226/255, blue: 217/255) - static let ink = Color(red: 26/255, green: 23/255, blue: 20/255) - static let inkMuted = Color(red: 150/255, green: 142/255, blue: 131/255) - static let coral = Color(red: 255/255, green: 90/255, blue: 54/255) - static let coralDeep = Color(red: 232/255, green: 67/255, blue: 31/255) + static var clayPaper: Color { Pal.current.clayPaper } + static var claySunk: Color { Pal.current.claySunk } + static var ink: Color { Pal.current.ink } + static var inkMuted: Color { Pal.current.inkMuted } + static let coral = Color(255, 90, 54) + static let coralDeep = Color(232, 67, 31) } private let zonePalette: [Color] = [ - Color(red: 124/255, green: 168/255, blue: 240/255), // Z1 blue - Color(red: 43/255, green: 182/255, blue: 115/255), // Z2 green - Color(red: 255/255, green: 90/255, blue: 54/255), // Z3 coral - Color(red: 232/255, green: 67/255, blue: 31/255), // Z4 deep - Color(red: 229/255, green: 72/255, blue: 77/255), // Z5 red + Color(124, 168, 240), // Z1 blue + Color(43, 182, 115), // Z2 green + Color(255, 90, 54), // Z3 coral + Color(232, 67, 31), // Z4 deep + Color(229, 72, 77), // Z5 red ] private func zoneColor(_ z: Int) -> Color { (z >= 1 && z <= 5) ? zonePalette[z - 1] : .inkMuted } @@ -59,13 +83,14 @@ private struct Clay: ViewModifier { var radius: CGFloat = 22 var fill: Color = .clayPaper func body(content: Content) -> some View { - content.background( + let dark = Pal.current.isDark + return content.background( RoundedRectangle(cornerRadius: radius, style: .continuous) .fill(LinearGradient(colors: [fill, fill.opacity(0.92)], startPoint: .topLeading, endPoint: .bottomTrailing)) .overlay(RoundedRectangle(cornerRadius: radius, style: .continuous) - .strokeBorder(.white.opacity(0.5), lineWidth: 1)) - .shadow(color: .black.opacity(0.16), radius: 8, x: 0, y: 5)) + .strokeBorder(.white.opacity(dark ? 0.08 : 0.5), lineWidth: 1)) + .shadow(color: .black.opacity(dark ? 0.45 : 0.16), radius: 8, x: 0, y: 5)) } } private extension View { diff --git a/lib/app.dart b/lib/app.dart index 8818c1d..5737f17 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -4,6 +4,8 @@ import 'package:provider/provider.dart'; import 'state/app_state.dart'; import 'theme/theme.dart'; +import 'theme/theme_controller.dart'; +import 'theme/theme_switcher.dart'; import 'theme/tokens.dart'; import 'ui/kit/kit.dart'; import 'ui/onboarding_screens.dart'; @@ -32,6 +34,13 @@ class _OpenStrapAppState extends State with WidgetsBindingObserver super.dispose(); } + @override + void didChangePlatformBrightness() { + // Keep the app in sync with the OS when the user is on "System". + context.read().updatePlatformBrightness( + WidgetsBinding.instance.platformDispatcher.platformBrightness); + } + @override void didChangeAppLifecycleState(AppLifecycleState state) { final app = context.read(); @@ -48,10 +57,15 @@ class _OpenStrapAppState extends State with WidgetsBindingObserver @override Widget build(BuildContext context) { + final theme = context.watch(); return MaterialApp( title: 'OpenStrap', debugShowCheckedModeBanner: false, - theme: buildOpenStrapTheme(), + theme: theme.lightTheme, + darkTheme: theme.darkTheme, + themeMode: theme.materialThemeMode, + builder: (context, child) => + ThemeSwitchOverlay(key: themeSwitchKey, child: child!), home: const _Gate(), ); } @@ -63,16 +77,21 @@ class _Gate extends StatelessWidget { @override Widget build(BuildContext context) { final app = context.watch(); + // Depend on the theme too → the whole home stack (onboarding screens, the + // shell + its tabs) rebuilds with fresh colours the instant the mode flips. + context.watch(); if (!app.initialized) { - return const Scaffold( + return Scaffold( body: Center(child: CircularProgressIndicator(color: AppColors.coral)), ); } - if (!app.backendChosen) return const BackendChoiceScreen(); - if (!app.isAuthenticated) return const AuthScreen(); - if (!app.profileComplete) return const ProfileSetupScreen(); - if (!app.isPaired) return const PairingScreen(); - return const _Shell(); + // Not const: these must be fresh instances so a theme flip re-runs their + // build (State is preserved — same type at the same position). + if (!app.backendChosen) return BackendChoiceScreen(); + if (!app.isAuthenticated) return AuthScreen(); + if (!app.profileComplete) return ProfileSetupScreen(); + if (!app.isPaired) return PairingScreen(); + return _Shell(); } } @@ -86,13 +105,17 @@ class _ShellState extends State<_Shell> { final _controller = PageController(); int _index = 0; - static const _pages = [ - TodayScreen(), - SleepScreen(), - HeartScreen(), - BodyScreen(), - WorkoutsScreen(), - ]; + // Built fresh on every build (not const) so a theme flip re-colours every tab, + // even the kept-alive ones the user isn't currently looking at. + // ignore: prefer_const_constructors — must be fresh instances so the + // kept-alive tabs re-colour on a theme flip (const would canonicalize them). + List get _pages => [ + TodayScreen(), + SleepScreen(), + HeartScreen(), + BodyScreen(), + WorkoutsScreen(), + ]; static const _nav = [ (Ic.home, 'Today'), @@ -157,8 +180,8 @@ class _LiveBannerState extends State<_LiveBanner> with SingleTickerProviderState child: GestureDetector( onTap: () { HapticFeedback.selectionClick(); - Navigator.of(context).push(MaterialPageRoute( - builder: (_) => LiveSessionScreen(workoutId: w.workoutId, type: w.type))); + Navigator.of(context).push(themedRoute( + (_) => LiveSessionScreen(workoutId: w.workoutId, type: w.type))); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: Sp.x4, vertical: Sp.x3), @@ -170,11 +193,11 @@ class _LiveBannerState extends State<_LiveBanner> with SingleTickerProviderState child: Row(children: [ FadeTransition(opacity: _pulse, child: Container( width: 10, height: 10, - decoration: const BoxDecoration(color: AppColors.coral, shape: BoxShape.circle))), + decoration: BoxDecoration(color: AppColors.coral, shape: BoxShape.circle))), const SizedBox(width: Sp.x3), Text('LIVE · ${w.type.toUpperCase()}', style: AppText.overline.copyWith(color: Colors.white70)), const Spacer(), - const AppIcon(Ic.heart, size: 15, color: AppColors.coral), + AppIcon(Ic.heart, size: 15, color: AppColors.coral), const SizedBox(width: 4), Text(w.currentHr > 0 ? '${w.currentHr}' : '—', style: AppText.metricSm.copyWith(color: Colors.white, fontSize: 16)), diff --git a/lib/main.dart b/lib/main.dart index 39b41ab..8bc01ff 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import 'app.dart'; import 'ble/ios_ble_restore.dart'; import 'state/app_state.dart'; +import 'theme/theme_controller.dart'; import 'widget/widget_service.dart'; Future main() async { @@ -12,9 +13,15 @@ Future main() async { // relaunch this runs too, so a band-triggered wake reaches runHeadlessSync. await IosBleRestore.init(); await WidgetService.init(); + // Resolve appearance (persisted choice + OS brightness) BEFORE the first frame + // so login/signup already paint in the right mode (Ember on Paper / Char). + final theme = await ThemeController.bootstrap(); runApp( - ChangeNotifierProvider( - create: (_) => AppState(), + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => AppState()), + ChangeNotifierProvider.value(value: theme), + ], child: const OpenStrapApp(), ), ); diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart index 2701f79..fbd49d9 100644 --- a/lib/theme/theme.dart +++ b/lib/theme/theme.dart @@ -1,12 +1,18 @@ -// OpenStrap v2 theme — Space Grotesk display + Inter body, ember-coral on paper. -// `AppText` is the type scale (replaces the old AppType). Numbers use Space -// Grotesk with tabular figures; body/labels use Inter. +// OpenStrap theme — Space Grotesk display + Inter body, ember-coral on paper +// (day) or char (night). `AppText` is the type scale; numbers use Space Grotesk +// with tabular figures, body/labels use Inter. Text colours resolve through the +// live `AppColors` getters, so the type scale follows the active mode for free. +// +// `buildOpenStrapTheme(palette)` builds a full ThemeData from an explicit +// [Palette] (not the live getters) so the light + dark ThemeData objects are +// each internally consistent regardless of which mode is currently active. import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'tokens.dart'; /// Type scale. Display + numerics → Space Grotesk; body/labels → Inter. +/// Colours come from the live [AppColors] getters → they track the active mode. class AppText { AppText._(); @@ -55,64 +61,72 @@ class AppText { color: AppColors.inkMuted); } -ThemeData buildOpenStrapTheme() { +/// Build the full theme from an explicit [Palette] so light/dark are each +/// self-consistent. Call with [kLightPalette] / [kDarkPalette]. +ThemeData buildOpenStrapTheme(Palette p) { final scheme = ColorScheme.fromSeed( - seedColor: AppColors.coral, - brightness: Brightness.light, + seedColor: p.coral, + brightness: p.brightness, ).copyWith( - surface: AppColors.surface, - primary: AppColors.coral, + surface: p.surface, + onSurface: p.ink, + primary: p.coral, onPrimary: Colors.white, - secondary: AppColors.coralDeep, + secondary: p.coralDeep, ); final base = ThemeData( useMaterial3: true, + brightness: p.brightness, colorScheme: scheme, - scaffoldBackgroundColor: AppColors.bg, - dividerColor: AppColors.divider, - splashColor: AppColors.coral.withValues(alpha: 0.08), - highlightColor: AppColors.coral.withValues(alpha: 0.05), + scaffoldBackgroundColor: p.bg, + dividerColor: p.divider, + splashColor: p.coral.withValues(alpha: 0.08), + highlightColor: p.coral.withValues(alpha: 0.05), textTheme: GoogleFonts.interTextTheme().apply( - bodyColor: AppColors.ink, - displayColor: AppColors.ink, + bodyColor: p.ink, + displayColor: p.ink, ), ); return base.copyWith( appBarTheme: AppBarTheme( - backgroundColor: AppColors.bg, + backgroundColor: p.bg, surfaceTintColor: Colors.transparent, - foregroundColor: AppColors.ink, + foregroundColor: p.ink, elevation: 0, centerTitle: false, - titleTextStyle: AppText.h2, + titleTextStyle: GoogleFonts.spaceGrotesk( + fontSize: 20, fontWeight: FontWeight.w700, height: 1.1, + letterSpacing: -0.3, color: p.ink), ), inputDecorationTheme: InputDecorationTheme( filled: true, - fillColor: AppColors.surface, + fillColor: p.surface, contentPadding: const EdgeInsets.symmetric(horizontal: Sp.x5, vertical: Sp.x4), - hintStyle: AppText.body.copyWith(color: AppColors.inkMuted), - labelStyle: AppText.bodySoft, + hintStyle: GoogleFonts.inter( + fontSize: 14.5, fontWeight: FontWeight.w400, color: p.inkMuted), + labelStyle: GoogleFonts.inter( + fontSize: 14.5, fontWeight: FontWeight.w400, color: p.inkSoft), border: OutlineInputBorder( borderRadius: BorderRadius.circular(R.cardSm), - borderSide: const BorderSide(color: AppColors.divider), + borderSide: BorderSide(color: p.divider), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(R.cardSm), - borderSide: const BorderSide(color: AppColors.divider), + borderSide: BorderSide(color: p.divider), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(R.cardSm), - borderSide: const BorderSide(color: AppColors.coral, width: 2), + borderSide: BorderSide(color: p.coral, width: 2), ), ), filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( - backgroundColor: AppColors.coral, + backgroundColor: p.coral, foregroundColor: Colors.white, - disabledBackgroundColor: AppColors.inkMuted.withValues(alpha: 0.35), + disabledBackgroundColor: p.inkMuted.withValues(alpha: 0.35), minimumSize: const Size(0, 56), elevation: 0, shape: @@ -122,9 +136,9 @@ ThemeData buildOpenStrapTheme() { ), outlinedButtonTheme: OutlinedButtonThemeData( style: OutlinedButton.styleFrom( - foregroundColor: AppColors.ink, + foregroundColor: p.ink, minimumSize: const Size(0, 56), - side: const BorderSide(color: AppColors.divider, width: 1.5), + side: BorderSide(color: p.divider, width: 1.5), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(R.pill)), textStyle: GoogleFonts.inter(fontSize: 15, fontWeight: FontWeight.w600), @@ -132,23 +146,22 @@ ThemeData buildOpenStrapTheme() { ), textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom( - foregroundColor: AppColors.coralDeep, + foregroundColor: p.coralDeep, textStyle: GoogleFonts.inter(fontSize: 14, fontWeight: FontWeight.w600), ), ), snackBarTheme: SnackBarThemeData( behavior: SnackBarBehavior.floating, - backgroundColor: AppColors.ink, + backgroundColor: p.isDark ? p.surfaceAlt : AppColors.night, contentTextStyle: GoogleFonts.inter(color: AppColors.onNight), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(R.chip)), ), - bottomSheetTheme: const BottomSheetThemeData( - backgroundColor: AppColors.surface, + bottomSheetTheme: BottomSheetThemeData( + backgroundColor: p.surface, surfaceTintColor: Colors.transparent, showDragHandle: true, - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.vertical(top: Radius.circular(R.card)), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(R.card)), ), ), ); diff --git a/lib/theme/theme_controller.dart b/lib/theme/theme_controller.dart new file mode 100644 index 0000000..fdc20f5 --- /dev/null +++ b/lib/theme/theme_controller.dart @@ -0,0 +1,121 @@ +// Theme controller — owns the user's appearance choice (System / Light / Dark), +// tracks the OS brightness, and resolves the two into the *effective* mode that +// MaterialApp paints. It keeps [AppColors.active] in lockstep (set synchronously +// before notifying) so the 546 `AppColors.x` call sites always resolve to the +// mode being rendered, and it drives the system status-bar icon brightness. +// +// First launch follows the OS: if the phone is in dark mode, OpenStrap opens in +// "Ember on Char" from the login/signup screen onward. The choice is persisted +// and editable later from onboarding and Profile; UI updates live on change. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../widget/widget_service.dart'; +import 'tokens.dart'; +import 'theme.dart'; + +/// What the user picked. `system` defers to the OS brightness. +enum AppThemeChoice { system, light, dark } + +extension AppThemeChoiceLabel on AppThemeChoice { + String get label => switch (this) { + AppThemeChoice.system => 'System', + AppThemeChoice.light => 'Light', + AppThemeChoice.dark => 'Dark', + }; +} + +class ThemeController extends ChangeNotifier { + static const String _kChoice = 'theme_choice'; // 'system' | 'light' | 'dark' + + AppThemeChoice _choice; + Brightness _platform; + + ThemeController._(this._choice, this._platform) { + _applyActive(); // make AppColors.active correct immediately + } + + /// Build synchronously from already-loaded inputs (used by [bootstrap]). + factory ThemeController.seed(AppThemeChoice choice, Brightness platform) => + ThemeController._(choice, platform); + + /// Load the persisted choice + current OS brightness and set [AppColors.active] + /// BEFORE the first frame. Call from main() before runApp so login/signup + /// already render in the right mode. + static Future bootstrap() async { + final prefs = await SharedPreferences.getInstance(); + final choice = _parse(prefs.getString(_kChoice)); + final platform = + WidgetsBinding.instance.platformDispatcher.platformBrightness; + final c = ThemeController._(choice, platform); + c._applySystemChrome(); + return c; + } + + static AppThemeChoice _parse(String? s) => switch (s) { + 'light' => AppThemeChoice.light, + 'dark' => AppThemeChoice.dark, + _ => AppThemeChoice.system, + }; + + AppThemeChoice get choice => _choice; + + /// The brightness actually being rendered. + Brightness get effective => switch (_choice) { + AppThemeChoice.light => Brightness.light, + AppThemeChoice.dark => Brightness.dark, + AppThemeChoice.system => _platform, + }; + + bool get isDark => effective == Brightness.dark; + + /// We resolve `system` ourselves and hand MaterialApp an explicit mode, so the + /// rendered brightness can never drift from [AppColors.active]. + ThemeMode get materialThemeMode => + isDark ? ThemeMode.dark : ThemeMode.light; + + ThemeData get lightTheme => buildOpenStrapTheme(kLightPalette); + ThemeData get darkTheme => buildOpenStrapTheme(kDarkPalette); + + /// User picked a mode (onboarding / profile). Updates live + persists. + Future setChoice(AppThemeChoice choice) async { + if (_choice == choice) return; + _choice = choice; + _applyActive(); + _applySystemChrome(); + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kChoice, choice.name); + } + + /// Called when the OS brightness changes (only matters under `system`). + void updatePlatformBrightness(Brightness b) { + if (_platform == b) return; + _platform = b; + if (_choice == AppThemeChoice.system) { + _applyActive(); + _applySystemChrome(); + notifyListeners(); + } + } + + void _applyActive() { + AppColors.active = isDark ? kDarkPalette : kLightPalette; + // Keep the iOS widget + Live Activity in the same mode (best-effort). + WidgetService.setThemeDark(isDark); + } + + void _applySystemChrome() { + // Status-bar (and Android nav-bar) icon brightness must oppose the surface. + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark, + statusBarBrightness: isDark ? Brightness.dark : Brightness.light, + systemNavigationBarColor: AppColors.bg, + systemNavigationBarIconBrightness: + isDark ? Brightness.light : Brightness.dark, + )); + } +} diff --git a/lib/theme/theme_switcher.dart b/lib/theme/theme_switcher.dart new file mode 100644 index 0000000..f5cbd3b --- /dev/null +++ b/lib/theme/theme_switcher.dart @@ -0,0 +1,121 @@ +// Smooth, foolproof theme switching. +// +// Two problems this solves: +// 1) Colours are global statics (AppColors.x), so the framework has no +// dependency edge telling it what to rebuild when the mode flips. We fix +// that by making every route rebuild: the home stack watches the controller +// (see app.dart), and pushed routes go through [themedRoute], whose body is +// a [_ThemeReactive] that depends on the controller and reconstructs its +// screen on change (State is preserved — same type at the same position). +// 2) A hard colour swap looks janky. [ThemeSwitchOverlay] snapshots the live +// frame the instant before the swap and cross-fades it out over the freshly +// re-coloured tree underneath — a clean dissolve, with the nav stack intact. + +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:provider/provider.dart'; + +import 'theme_controller.dart'; +import 'tokens.dart'; + +/// Wrap a screen builder so the route rebuilds on every mode change. +/// Use everywhere instead of `MaterialPageRoute(builder: ...)`. +PageRoute themedRoute(WidgetBuilder builder, {bool fullscreenDialog = false}) => + MaterialPageRoute( + fullscreenDialog: fullscreenDialog, + builder: (ctx) => _ThemeReactive(builder: builder), + ); + +class _ThemeReactive extends StatelessWidget { + final WidgetBuilder builder; + const _ThemeReactive({required this.builder}); + @override + Widget build(BuildContext context) { + // Depend on the controller → this route's body rebuilds when the mode flips, + // reconstructing the screen with fresh AppColors while keeping its State. + context.watch(); + return builder(context); + } +} + +/// Global handle so the appearance picker can trigger the cross-fade from +/// anywhere (e.g. inside a pushed Profile route). +final GlobalKey themeSwitchKey = + GlobalKey(); + +/// Captures the current frame and cross-fades it out after a theme swap. +/// Installed once via MaterialApp.builder, above the Navigator. +class ThemeSwitchOverlay extends StatefulWidget { + final Widget child; + const ThemeSwitchOverlay({super.key, required this.child}); + @override + State createState() => ThemeSwitchOverlayState(); +} + +class ThemeSwitchOverlayState extends State + with SingleTickerProviderStateMixin { + final GlobalKey _boundaryKey = GlobalKey(); + late final AnimationController _fade = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 420), + )..addListener(() => setState(() {})); + ui.Image? _snapshot; + + @override + void dispose() { + _fade.dispose(); + _snapshot?.dispose(); + super.dispose(); + } + + /// Snapshot the old frame, run [applySwitch] (which swaps the palette and + /// notifies), then dissolve the snapshot to reveal the re-coloured tree. + void run(VoidCallback applySwitch) { + final boundary = _boundaryKey.currentContext?.findRenderObject(); + if (boundary is! RenderRepaintBoundary) { + applySwitch(); + return; + } + try { + final dpr = MediaQuery.of(context).devicePixelRatio; + final shot = boundary.toImageSync(pixelRatio: dpr); + _snapshot?.dispose(); + // Paint the captured (old) frame on top in this same frame, so swapping + // the palette underneath is never visible until the dissolve runs. + setState(() => _snapshot = shot); + } catch (_) { + // toImageSync can fail mid-frame; fall back to an instant swap. + applySwitch(); + return; + } + applySwitch(); + _fade + ..value = 0 + ..forward().whenComplete(() { + if (!mounted) return; + final old = _snapshot; + setState(() => _snapshot = null); + old?.dispose(); + }); + } + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + RepaintBoundary(key: _boundaryKey, child: widget.child), + if (_snapshot != null) + Positioned.fill( + child: IgnorePointer( + child: Opacity( + opacity: (1.0 - Motion.curve.transform(_fade.value)).clamp(0.0, 1.0), + child: RawImage(image: _snapshot, fit: BoxFit.cover), + ), + ), + ), + ], + ); + } +} diff --git a/lib/theme/tokens.dart b/lib/theme/tokens.dart index 8652d71..3912707 100644 --- a/lib/theme/tokens.dart +++ b/lib/theme/tokens.dart @@ -1,60 +1,200 @@ -// Design tokens — OpenStrap v2 "Ember on Paper". -// Warm off-white surfaces, near-black ink, a single confident coral accent. +// Design tokens — OpenStrap "Ember on Paper" (day) / "Ember on Char" (night). +// Day: warm off-white surfaces, near-black ink, a single confident coral accent. +// Night: the paper burns down to warm charcoal — same ember, never cold black. +// The accent stays coral across both modes; warmth is the constant, not lightness. +// // Big tabular numbers (Space Grotesk) over clean body (Inter) — see theme.dart. // The honesty system (confidence dots, est./relative/beta labels) is preserved. +// +// Mode switching: every mode-varying role lives on [Palette]; [AppColors] exposes +// the same names it always did, resolved through [AppColors.active]. The theme +// controller swaps `active` (synchronously) the instant the mode changes, so the +// 546 `AppColors.x` call sites keep working untouched and re-theme on rebuild. import 'package:flutter/material.dart'; -/// Palette — warm paper + coral. -class AppColors { - AppColors._(); +/// A complete set of mode-varying colour roles. Two const instances exist +/// ([kLightPalette], [kDarkPalette]); the active one is swapped at runtime. +@immutable +class Palette { + final Brightness brightness; // Surfaces. - static const bg = Color(0xFFF4F1EC); // warm paper background - static const surface = Color(0xFFFFFFFF); // cards - static const surfaceAlt = Color(0xFFECE7DF); // inset / skeleton base - static const surfaceSunk = Color(0xFFEDE9E1); // subtle wells - static const cool = Color(0xFFE7EBF5); // cool secondary section (ref #3 blue) - static const coolInk = Color(0xFF2B3350); // ink on the cool surface - static const divider = Color(0xFFE6E0D6); + final Color bg; + final Color surface; + final Color surfaceAlt; + final Color surfaceSunk; + final Color cool; + final Color coolInk; + final Color divider; // Ink. - static const ink = Color(0xFF16130F); // near-black, warm - static const inkSoft = Color(0xFF6B6157); // secondary - static const inkMuted = Color(0xFFA59C90); // tertiary / placeholders + final Color ink; + final Color inkSoft; + final Color inkMuted; + + // Accent — ember coral. + final Color coral; + final Color coralDeep; + final Color coralSoft; + final Color coralInk; + + // Status. + final Color good; + final Color goodSoft; + final Color warn; + final Color warnSoft; + final Color bad; + final Color badSoft; + + // Confidence + load (independent tones; the rest derive from status). + final Color confLow; + final Color loadDetraining; + + const Palette({ + required this.brightness, + required this.bg, + required this.surface, + required this.surfaceAlt, + required this.surfaceSunk, + required this.cool, + required this.coolInk, + required this.divider, + required this.ink, + required this.inkSoft, + required this.inkMuted, + required this.coral, + required this.coralDeep, + required this.coralSoft, + required this.coralInk, + required this.good, + required this.goodSoft, + required this.warn, + required this.warnSoft, + required this.bad, + required this.badSoft, + required this.confLow, + required this.loadDetraining, + }); + + bool get isDark => brightness == Brightness.dark; +} + +/// Day — "Ember on Paper". The original, beloved palette, unchanged in value. +const Palette kLightPalette = Palette( + brightness: Brightness.light, + bg: Color(0xFFF4F1EC), // warm paper background + surface: Color(0xFFFFFFFF), // cards + surfaceAlt: Color(0xFFECE7DF), // inset / skeleton base + surfaceSunk: Color(0xFFEDE9E1), // subtle wells + cool: Color(0xFFE7EBF5), // cool secondary section + coolInk: Color(0xFF2B3350), // ink on the cool surface + divider: Color(0xFFE6E0D6), + ink: Color(0xFF16130F), // near-black, warm + inkSoft: Color(0xFF6B6157), // secondary + inkMuted: Color(0xFFA59C90), // tertiary / placeholders + coral: Color(0xFFFF5A36), + coralDeep: Color(0xFFE8431F), + coralSoft: Color(0xFFFFE7DF), // tint fill + coralInk: Color(0xFF7A2A16), // ink on coralSoft + good: Color(0xFF2BB673), + goodSoft: Color(0xFFDBF3E7), + warn: Color(0xFFF5A623), + warnSoft: Color(0xFFFBEBCF), + bad: Color(0xFFE5484D), + badSoft: Color(0xFFFAE0E0), + confLow: Color(0xFFC9C0B4), + loadDetraining: Color(0xFF7CA8F0), +); + +/// Night — "Ember on Char". Warm charcoal, never cold black. Ink is the paper +/// colour; coral lifts ~8% so it reads cleanly on dark; the pale "*Soft" tints +/// become deep warm ember/earth fills so light ink sits on them comfortably. +const Palette kDarkPalette = Palette( + brightness: Brightness.dark, + bg: Color(0xFF14110D), // warm near-black char + surface: Color(0xFF1E1A15), // cards, lifted off bg + surfaceAlt: Color(0xFF2A251F), // inset / skeleton base + surfaceSunk: Color(0xFF100E0A), // wells, darker than bg + cool: Color(0xFF20242E), // cool secondary, darkened + coolInk: Color(0xFFC3CADB), // ink on the cool surface + divider: Color(0xFF302A22), + ink: Color(0xFFF1ECE3), // warm off-white — the paper becomes the ink + inkSoft: Color(0xFFB6AB9C), + inkMuted: Color(0xFF7E7466), + coral: Color(0xFFFF6B47), // a hair brighter on dark + coralDeep: Color(0xFFFF8159), // "deep" = stronger/lighter coral on dark text + coralSoft: Color(0xFF3A2018), // deep warm ember tint fill + coralInk: Color(0xFFFFB59E), // light coral text on coralSoft + good: Color(0xFF34C988), + goodSoft: Color(0xFF15281F), + warn: Color(0xFFF7B53A), + warnSoft: Color(0xFF31280F), + bad: Color(0xFFF26168), + badSoft: Color(0xFF331A1B), + confLow: Color(0xFF5A5248), + loadDetraining: Color(0xFF8FB4F2), +); + +/// Palette — warm paper + coral. Same public names as before; mode-varying roles +/// now resolve through [active], which the theme controller swaps at runtime. +class AppColors { + AppColors._(); + + /// The currently-rendered palette. Swapped (synchronously) by the theme + /// controller before the tree rebuilds, so getters below always match the + /// mode MaterialApp is painting. + static Palette active = kLightPalette; + + static bool get isDark => active.isDark; + + // ── Surfaces (mode-varying) ── + static Color get bg => active.bg; + static Color get surface => active.surface; + static Color get surfaceAlt => active.surfaceAlt; + static Color get surfaceSunk => active.surfaceSunk; + static Color get cool => active.cool; + static Color get coolInk => active.coolInk; + static Color get divider => active.divider; + + // ── Ink (mode-varying) ── + static Color get ink => active.ink; + static Color get inkSoft => active.inkSoft; + static Color get inkMuted => active.inkMuted; - // Dark hero surfaces (device card, splash overlays). + // ── Dark hero surfaces — INVARIANT across modes (always-dark cards: the + // device card, the live-workout screen, splash overlays). ── static const night = Color(0xFF181613); static const nightAlt = Color(0xFF24211D); static const onNight = Color(0xFFF4F1EC); static const onNightSoft = Color(0xFFA8A096); - // Accent — ember coral. - static const coral = Color(0xFFFF5A36); - static const coralDeep = Color(0xFFE8431F); - static const coralSoft = Color(0xFFFFE7DF); // tint fill - static const coralInk = Color(0xFF7A2A16); // ink on coralSoft - - // Status (used sparingly — coral stays the hero). - static const good = Color(0xFF2BB673); - static const goodSoft = Color(0xFFDBF3E7); - static const warn = Color(0xFFF5A623); - static const warnSoft = Color(0xFFFBEBCF); - static const bad = Color(0xFFE5484D); - static const badSoft = Color(0xFFFAE0E0); - - // Confidence dot. - static const confHigh = good; - static const confMid = warn; - static const confLow = Color(0xFFC9C0B4); - - // Load (ACWR) bands. - static const loadDetraining = Color(0xFF7CA8F0); - static const loadOptimal = good; - static const loadCaution = warn; - static const loadHigh = bad; - - /// Coral→deep-coral glow gradient pair. + // ── Accent — ember coral (mode-varying) ── + static Color get coral => active.coral; + static Color get coralDeep => active.coralDeep; + static Color get coralSoft => active.coralSoft; + static Color get coralInk => active.coralInk; + + // ── Status (mode-varying) ── + static Color get good => active.good; + static Color get goodSoft => active.goodSoft; + static Color get warn => active.warn; + static Color get warnSoft => active.warnSoft; + static Color get bad => active.bad; + static Color get badSoft => active.badSoft; + + // ── Confidence dot ── + static Color get confHigh => active.good; + static Color get confMid => active.warn; + static Color get confLow => active.confLow; + + // ── Load (ACWR) bands ── + static Color get loadDetraining => active.loadDetraining; + static Color get loadOptimal => active.good; + static Color get loadCaution => active.warn; + static Color get loadHigh => active.bad; + + // ── Live-session ember glow — INVARIANT (always on the dark live screen). ── static const glow1 = Color(0xFFFF7A4D); static const glow2 = Color(0xFFFF3D1F); @@ -98,7 +238,8 @@ class R { static const pill = 999.0; } -/// Soft warm elevation. Light theme leans on gentle shadow, not borders. +/// Soft warm elevation. Light theme leans on gentle shadow; dark theme leans on +/// a hairline border + lifted surface (drop shadows vanish on char), see ProCard. class Shadows { Shadows._(); static const card = [ @@ -111,6 +252,10 @@ class Shadows { static const coral = [ BoxShadow(color: Color(0x40FF5A36), blurRadius: 28, offset: Offset(0, 12)), ]; + + /// Elevation for a card by mode. In dark we drop shadows entirely (invisible + /// on char) and let the lifted surface + border carry depth. + static List cardFor(bool dark) => dark ? const [] : card; } /// Motion. diff --git a/lib/ui/activity/activity_screen.dart b/lib/ui/activity/activity_screen.dart index deddcc7..dc7161b 100644 --- a/lib/ui/activity/activity_screen.dart +++ b/lib/ui/activity/activity_screen.dart @@ -8,6 +8,7 @@ import '../../models/payloads.dart'; import '../../net/api_client.dart'; import '../../state/app_state.dart'; import '../../theme/theme.dart'; +import '../../theme/theme_switcher.dart'; import '../../theme/tokens.dart'; import '../kit/charts.dart'; import '../kit/kit.dart'; @@ -23,7 +24,7 @@ class ActivityScreen extends StatefulWidget { class _ActivityScreenState extends State with ScreenLoaderMixin { // HR zone palette: light → vivid coral, deep red top end. - static const zoneColors = [ + static final zoneColors = [ AppColors.loadDetraining, AppColors.good, AppColors.warn, @@ -91,8 +92,7 @@ class _ActivityScreenState extends State app.startWorkout(targetKcal: 300); Navigator.push( context, - MaterialPageRoute( - builder: (_) => const LiveSessionScreen()), + themedRoute((_) => const LiveSessionScreen()), ); }) : null, @@ -173,7 +173,7 @@ class _ActivityScreenState extends State ProCard( child: Row( children: [ - const AppIcon(Ic.run, size: 22, color: AppColors.inkMuted), + AppIcon(Ic.run, size: 22, color: AppColors.inkMuted), const SizedBox(width: Sp.x3), Expanded( child: Text('No workouts auto-detected today.', @@ -204,7 +204,7 @@ class _ActivityScreenState extends State Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - const AppIcon(Ic.strain, size: 18, color: AppColors.coral), + AppIcon(Ic.strain, size: 18, color: AppColors.coral), const SizedBox(width: Sp.x2), Text('DAY STRAIN', style: AppText.overline), if (!strain.isEmpty) ...[ @@ -239,7 +239,7 @@ class _ActivityScreenState extends State mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ - const AppIcon(Ic.fire, size: 18, color: AppColors.coralDeep), + AppIcon(Ic.fire, size: 18, color: AppColors.coralDeep), const SizedBox(width: Sp.x2), if (cals.isEmpty) metricDash(22) @@ -249,7 +249,7 @@ class _ActivityScreenState extends State Text('active cal', style: AppText.caption.copyWith(color: AppColors.inkMuted)), const SizedBox(width: Sp.x2), - const Tag('est', color: AppColors.warn), + Tag('est', color: AppColors.warn), ], ), ], @@ -399,7 +399,7 @@ class _SessionCard extends StatelessWidget { color: AppColors.coralSoft, borderRadius: BorderRadius.circular(R.chip), ), - child: const AppIcon(Ic.run, size: 18, color: AppColors.coral), + child: AppIcon(Ic.run, size: 18, color: AppColors.coral), ), const SizedBox(width: Sp.x3), Expanded( @@ -435,7 +435,7 @@ class _SessionCard extends StatelessWidget { _stat('Max HR', s.maxHr, 'bpm'), _stat('HRR60', s.hrr60, 'bpm'), _stat('Active cal', s.calories, '', - tag: const Tag('est', color: AppColors.warn)), + tag: Tag('est', color: AppColors.warn)), ], ), if (hasZones) ...[ @@ -516,7 +516,7 @@ class _TopTitle extends StatelessWidget { if (freshness != null) ...[ const SizedBox(height: 2), Row(children: [ - const AppIcon(Ic.cloud, size: 14, color: AppColors.inkMuted), + AppIcon(Ic.cloud, size: 14, color: AppColors.inkMuted), const SizedBox(width: Sp.x1), Text(freshness!, style: AppText.captionMuted), ]), @@ -551,7 +551,7 @@ class _StartButton extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const AppIcon(Ic.run, size: 16, color: AppColors.coralDeep), + AppIcon(Ic.run, size: 16, color: AppColors.coralDeep), const SizedBox(width: Sp.x2), Text('START', style: AppText.label.copyWith( @@ -606,7 +606,7 @@ class _ErrorCard extends StatelessWidget { padding: const EdgeInsets.all(Sp.x7), child: Column( children: [ - const AppIcon(Ic.cloud, size: 28, color: AppColors.inkMuted), + AppIcon(Ic.cloud, size: 28, color: AppColors.inkMuted), const SizedBox(height: Sp.x3), Text('Couldn\'t load activity', style: AppText.h2, textAlign: TextAlign.center), diff --git a/lib/ui/activity/live_session_screen.dart b/lib/ui/activity/live_session_screen.dart index 85d244d..c8fd5cf 100644 --- a/lib/ui/activity/live_session_screen.dart +++ b/lib/ui/activity/live_session_screen.dart @@ -12,6 +12,7 @@ import 'package:provider/provider.dart'; import '../../state/app_state.dart'; import '../../theme/theme.dart'; +import '../../theme/theme_switcher.dart'; import '../../theme/tokens.dart'; import '../kit/kit.dart'; import '../workouts/workouts_screen.dart' show WorkoutDetailScreen; @@ -32,7 +33,7 @@ class _ZoneMeta { const _ZoneMeta(this.label, this.name, this.color); } -const List<_ZoneMeta> _zones = [ +final List<_ZoneMeta> _zones = [ _ZoneMeta('Z0', 'Resting', AppColors.cool), _ZoneMeta('Z1', 'Warm-up', AppColors.loadDetraining), _ZoneMeta('Z2', 'Fat burn', AppColors.good), @@ -205,7 +206,7 @@ class _LiveSessionScreenState extends State try { if (id != null) await app.api?.endWorkout(id); } catch (_) {} if (!mounted) return; if (id != null) { - Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => WorkoutDetailScreen(id: id))); + Navigator.of(context).pushReplacement(themedRoute((_) => WorkoutDetailScreen(id: id))); } else { Navigator.of(context).pop(); } @@ -253,7 +254,7 @@ class _LiveSessionScreenState extends State _pill(AppIcon(Ic.clock, size: 15, color: Colors.white60), _fmt(w.elapsed)), if (_redStreak.inSeconds >= 5) ...[ const SizedBox(height: Sp.x2), - _pill(const AppIcon(Ic.fire, size: 14, color: AppColors.coral), + _pill(AppIcon(Ic.fire, size: 14, color: AppColors.coral), '${_fmt(_redStreak)} in the red', tint: AppColors.coral), ], ]), diff --git a/lib/ui/activity/strain_detail_screen.dart b/lib/ui/activity/strain_detail_screen.dart index 4c09a84..a506691 100644 --- a/lib/ui/activity/strain_detail_screen.dart +++ b/lib/ui/activity/strain_detail_screen.dart @@ -278,7 +278,7 @@ class _StrainDetailScreenState extends State { child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ if (acwr != null) ...[ Row(children: [ - const AppIcon(Ic.strain, size: 18, color: AppColors.coralDeep), + AppIcon(Ic.strain, size: 18, color: AppColors.coralDeep), const SizedBox(width: Sp.x2), Text('Acute:chronic load', style: AppText.label), const Spacer(), @@ -315,7 +315,7 @@ class _StrainDetailScreenState extends State { color: AppColors.coralSoft, borderRadius: BorderRadius.circular(R.chip), ), - child: const AppIcon(Ic.strain, + child: AppIcon(Ic.strain, size: 16, color: AppColors.coralDeep), ), const SizedBox(width: Sp.x2), @@ -373,7 +373,7 @@ class _StrainDetailScreenState extends State { Widget _zonesCard() { final zones = _zones(); - const palette = [ + final palette = [ AppColors.loadDetraining, AppColors.good, AppColors.warn, @@ -472,11 +472,11 @@ class _StrainDetailScreenState extends State { child: Row(children: [ Container( padding: const EdgeInsets.all(Sp.x3), - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.coralSoft, shape: BoxShape.circle, ), - child: const AppIcon(Ic.run, size: 20, color: AppColors.coralDeep), + child: AppIcon(Ic.run, size: 20, color: AppColors.coralDeep), ), const SizedBox(width: Sp.x4), Expanded( @@ -515,11 +515,11 @@ class _StrainDetailScreenState extends State { Row(children: [ Container( padding: const EdgeInsets.all(Sp.x3), - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.coralSoft, shape: BoxShape.circle, ), - child: const AppIcon(Ic.run, size: 18, color: AppColors.coralDeep), + child: AppIcon(Ic.run, size: 18, color: AppColors.coralDeep), ), const SizedBox(width: Sp.x3), Expanded( @@ -561,8 +561,8 @@ class _StrainDetailScreenState extends State { // ── states ───────────────────────────────────────────────────────────────── - Widget _loading() => const ProCard( - padding: EdgeInsets.all(Sp.x6), + Widget _loading() => ProCard( + padding: const EdgeInsets.all(Sp.x6), child: SizedBox( height: 320, child: Center(child: CircularProgressIndicator(color: AppColors.coral)), @@ -576,7 +576,7 @@ class _StrainDetailScreenState extends State { children: [ Container( padding: const EdgeInsets.all(Sp.x4), - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.coralSoft, shape: BoxShape.circle, ), diff --git a/lib/ui/coach/coach_screen.dart b/lib/ui/coach/coach_screen.dart index 909757f..7ec9cdc 100644 --- a/lib/ui/coach/coach_screen.dart +++ b/lib/ui/coach/coach_screen.dart @@ -49,7 +49,7 @@ class CoachScreen extends StatelessWidget { if (coach.summary.isNotEmpty) GlowCard( child: Row(children: [ - const AppIcon(Ic.info, size: 20, color: AppColors.coralDeep), + AppIcon(Ic.info, size: 20, color: AppColors.coralDeep), const SizedBox(width: Sp.x3), Expanded(child: Text(coach.summary, style: AppText.title)), ]), @@ -64,7 +64,7 @@ class CoachScreen extends StatelessWidget { if (plan.isEmpty) ProCard( child: Row(children: [ - const AppIcon(Ic.check, size: 22, color: AppColors.good), + AppIcon(Ic.check, size: 22, color: AppColors.good), const SizedBox(width: Sp.x3), Expanded( child: Text('Nothing flagged — carry on with your day.', @@ -79,7 +79,7 @@ class CoachScreen extends StatelessWidget { const SizedBox(height: Sp.x4), Row(children: [ - const AppIcon(Ic.shield, size: 14, color: AppColors.inkMuted), + AppIcon(Ic.shield, size: 14, color: AppColors.inkMuted), const SizedBox(width: Sp.x2), Expanded( child: Text( @@ -100,7 +100,7 @@ class CoachScreen extends StatelessWidget { return ProCard( child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ - const AppIcon(Ic.strain, size: 19, color: AppColors.coral), + AppIcon(Ic.strain, size: 19, color: AppColors.coral), const SizedBox(width: Sp.x2), Text("Today's strain target", style: AppText.h2), ]), @@ -159,7 +159,7 @@ class CoachScreen extends StatelessWidget { if (s.target != null) ...[ const SizedBox(height: Sp.x3), Row(children: [ - const AppIcon(Ic.strain, size: 15, color: AppColors.coralDeep), + AppIcon(Ic.strain, size: 15, color: AppColors.coralDeep), const SizedBox(width: 6), Text(s.target!, style: AppText.label.copyWith(color: AppColors.coralDeep)), ]), diff --git a/lib/ui/journal/journal_screen.dart b/lib/ui/journal/journal_screen.dart index eb5d27e..a2d729d 100644 --- a/lib/ui/journal/journal_screen.dart +++ b/lib/ui/journal/journal_screen.dart @@ -229,7 +229,7 @@ class _JournalScreenState extends State { children: [ Row( children: [ - const AppIcon(Ic.edit, size: 19, color: AppColors.coral), + AppIcon(Ic.edit, size: 19, color: AppColors.coral), const SizedBox(width: Sp.x2), Expanded( child: Text( @@ -240,7 +240,7 @@ class _JournalScreenState extends State { if (!_isToday) GestureDetector( onTap: () => _bindEditor(_fmtDate(DateTime.now())), - child: const Tag('today', color: AppColors.coral), + child: Tag('today', color: AppColors.coral), ), ], ), @@ -345,9 +345,9 @@ class _JournalScreenState extends State { overflow: TextOverflow.ellipsis), ), if (active) - const Tag('editing', color: AppColors.coral) + Tag('editing', color: AppColors.coral) else - const AppIcon(Ic.edit, size: 16, color: AppColors.inkMuted), + AppIcon(Ic.edit, size: 16, color: AppColors.inkMuted), ], ), if (row.tags.isNotEmpty) ...[ @@ -474,7 +474,7 @@ class _JournalScreenState extends State { Widget _honestyFooter() { return Row( children: [ - const AppIcon(Ic.info, size: 14, color: AppColors.inkMuted), + AppIcon(Ic.info, size: 14, color: AppColors.inkMuted), const SizedBox(width: Sp.x2), Expanded( child: Text( @@ -499,7 +499,7 @@ class _JournalScreenState extends State { children: [ Container( padding: const EdgeInsets.all(Sp.x4), - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.coralSoft, shape: BoxShape.circle, ), diff --git a/lib/ui/journey/journey_screen.dart b/lib/ui/journey/journey_screen.dart index 4a1e247..cac2ea2 100644 --- a/lib/ui/journey/journey_screen.dart +++ b/lib/ui/journey/journey_screen.dart @@ -484,7 +484,7 @@ class _JourneyScreenState extends State { color: AppColors.coralSoft, borderRadius: BorderRadius.circular(R.chip), ), - child: const AppIcon(Ic.run, size: 18, color: AppColors.coralDeep), + child: AppIcon(Ic.run, size: 18, color: AppColors.coralDeep), ), const SizedBox(width: Sp.x3), Expanded( @@ -554,11 +554,11 @@ class _JourneyScreenState extends State { children: [ for (int i = 0; i < shown.length; i++) ...[ if (i > 0) - const Divider(height: 1, color: AppColors.divider), + Divider(height: 1, color: AppColors.divider), _eventRow(shown[i]), ], if (extra > 0) ...[ - const Divider(height: 1, color: AppColors.divider), + Divider(height: 1, color: AppColors.divider), Padding( padding: const EdgeInsets.symmetric(vertical: Sp.x3), child: Text('+$extra more events', @@ -577,7 +577,7 @@ class _JourneyScreenState extends State { return Padding( padding: const EdgeInsets.symmetric(vertical: Sp.x3), child: Row(children: [ - const AppIcon(Ic.info, size: 16, color: AppColors.inkMuted), + AppIcon(Ic.info, size: 16, color: AppColors.inkMuted), const SizedBox(width: Sp.x3), Expanded(child: Text(_eventLabel(id), style: AppText.body)), Text(_hm(ts), @@ -588,8 +588,8 @@ class _JourneyScreenState extends State { // ── states ───────────────────────────────────────────────────────────────────── - Widget _loading() => const ProCard( - padding: EdgeInsets.all(Sp.x6), + Widget _loading() => ProCard( + padding: const EdgeInsets.all(Sp.x6), child: SizedBox( height: 360, child: @@ -604,7 +604,7 @@ class _JourneyScreenState extends State { children: [ Container( padding: const EdgeInsets.all(Sp.x4), - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.coralSoft, shape: BoxShape.circle, ), diff --git a/lib/ui/kit/charts.dart b/lib/ui/kit/charts.dart index 8d80f66..e655c0d 100644 --- a/lib/ui/kit/charts.dart +++ b/lib/ui/kit/charts.dart @@ -81,13 +81,14 @@ class _RingPainter extends CustomPainter { /// Tiny sparkline bars (for inside cards). Values normalized to their own max. class MiniBars extends StatelessWidget { final List values; - final Color color; + final Color? color; final double height; final double gap; const MiniBars(this.values, - {super.key, this.color = AppColors.coral, this.height = 40, this.gap = 3}); + {super.key, this.color, this.height = 40, this.gap = 3}); @override Widget build(BuildContext context) { + final color = this.color ?? AppColors.coral; if (values.isEmpty) return SizedBox(height: height); final maxV = values.reduce(math.max); return SizedBox( @@ -129,7 +130,7 @@ class MiniBars extends StatelessWidget { class LabeledBars extends StatelessWidget { final List values; final List labels; - final Color color; + final Color? color; final double height; final int? highlight; final bool showValues; @@ -139,7 +140,7 @@ class LabeledBars extends StatelessWidget { super.key, required this.values, required this.labels, - this.color = AppColors.coral, + this.color, this.height = 200, this.highlight, this.showValues = true, @@ -156,6 +157,7 @@ class LabeledBars extends StatelessWidget { @override Widget build(BuildContext context) { + final color = this.color ?? AppColors.coral; final maxV = values.isEmpty ? 1.0 : math.max(1.0, values.reduce(math.max)); return SizedBox( height: height, @@ -219,12 +221,12 @@ 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 Color? color; final double height; - const AreaSpark(this.values, - {super.key, this.color = AppColors.coral, this.height = 90}); + 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, @@ -275,12 +277,13 @@ class AreaSpark extends StatelessWidget { class DotMatrix extends StatelessWidget { final List values; final int rows; - final Color color; + final Color? color; final double cell; const DotMatrix(this.values, - {super.key, this.rows = 12, this.color = AppColors.coral, this.cell = 12}); + {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) { @@ -366,7 +369,7 @@ class StatTile extends StatelessWidget { final num? deltaPct; final bool deltaGoodIsUp; final List? spark; - final Color accent; + final Color? accent; final double? confidence; final Widget? tag; final VoidCallback? onTap; @@ -379,13 +382,14 @@ class StatTile extends StatelessWidget { this.deltaPct, this.deltaGoodIsUp = true, this.spark, - this.accent = AppColors.coral, + 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( @@ -532,11 +536,12 @@ class FormChart extends StatelessWidget { /// 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 Color? color; final double cell; - const CalendarHeatmap({super.key, required this.days, this.color = AppColors.good, this.cell = 16}); + 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). diff --git a/lib/ui/kit/kit.dart b/lib/ui/kit/kit.dart index 936467f..c53e19f 100644 --- a/lib/ui/kit/kit.dart +++ b/lib/ui/kit/kit.dart @@ -4,7 +4,10 @@ import 'package:flutter/material.dart'; import 'package:hugeicons/hugeicons.dart'; +import 'package:provider/provider.dart'; import '../../theme/theme.dart'; +import '../../theme/theme_controller.dart'; +import '../../theme/theme_switcher.dart'; import '../../theme/tokens.dart'; import '../../models/metric.dart'; @@ -64,25 +67,33 @@ class ProCard extends StatelessWidget { final Widget child; final EdgeInsetsGeometry padding; final VoidCallback? onTap; - final Color color; - final List shadow; + final Color? color; + final List? shadow; const ProCard({ super.key, required this.child, this.padding = const EdgeInsets.all(Sp.x5), this.onTap, - this.color = AppColors.surface, - this.shadow = Shadows.card, + this.color, + this.shadow, }); @override Widget build(BuildContext context) { + final dark = AppColors.isDark; + final fill = color ?? AppColors.surface; + // Dark elevation comes from the lifted surface + a hairline border; drop + // shadows are invisible on char. Light keeps its soft warm shadow. + final resolvedShadow = shadow ?? Shadows.cardFor(dark); final card = AnimatedContainer( duration: Motion.fast, padding: padding, decoration: BoxDecoration( - color: color, + color: fill, borderRadius: BorderRadius.circular(R.card), - boxShadow: shadow, + boxShadow: resolvedShadow, + border: dark + ? Border.all(color: AppColors.divider, width: 1) + : null, ), child: child, ); @@ -104,18 +115,21 @@ class GlowCard extends StatelessWidget { final Widget child; final EdgeInsetsGeometry padding; final Alignment glowAlign; - final Color glow; + final Color? glow; final VoidCallback? onTap; const GlowCard({ super.key, required this.child, this.padding = const EdgeInsets.all(Sp.x5), this.glowAlign = const Alignment(0.9, 1.1), - this.glow = AppColors.coral, + this.glow, this.onTap, }); @override Widget build(BuildContext context) { + final glowColor = glow ?? AppColors.coral; + // On char the blob blooms — keep it a low, warm ember; on paper it can sing. + final glowAlpha = AppColors.isDark ? 0.28 : 0.55; final body = ClipRRect( borderRadius: BorderRadius.circular(R.card), child: Stack( @@ -126,7 +140,10 @@ class GlowCard extends StatelessWidget { gradient: RadialGradient( center: glowAlign, radius: 0.9, - colors: [glow.withValues(alpha: 0.55), Colors.transparent], + colors: [ + glowColor.withValues(alpha: glowAlpha), + Colors.transparent + ], ), ), ), @@ -184,7 +201,7 @@ class SectionHeader extends StatelessWidget { Text(trailing!, style: AppText.label.copyWith(color: AppColors.coralDeep)), const SizedBox(width: 2), - const AppIcon(Ic.arrowRight, + AppIcon(Ic.arrowRight, size: 16, color: AppColors.coralDeep), ]), ), @@ -229,7 +246,10 @@ class SegToggle extends StatelessWidget { ), child: Text(options[i], style: AppText.label.copyWith( - color: sel ? AppColors.onNight : AppColors.inkSoft, + // The selected pill is AppColors.ink (dark in light mode, + // light in dark mode). The label must contrast with it: + // AppColors.surface inverts correctly in both modes. + color: sel ? AppColors.surface : AppColors.inkSoft, fontWeight: FontWeight.w700, )), ), @@ -307,6 +327,58 @@ class BaselineDeltaChip extends StatelessWidget { } } +/// Appearance picker — System / Light / Dark, wired live to [ThemeController]. +/// Used in onboarding (inline) and Profile (inside a ProCard). Switching updates +/// the whole app immediately. "System" follows the phone; the others pin a mode. +class AppearanceSelector extends StatelessWidget { + /// Show the "APPEARANCE" overline + a one-line description above the toggle. + final bool labeled; + const AppearanceSelector({super.key, this.labeled = true}); + + @override + Widget build(BuildContext context) { + final ctrl = context.watch(); + final idx = switch (ctrl.choice) { + AppThemeChoice.system => 0, + AppThemeChoice.light => 1, + AppThemeChoice.dark => 2, + }; + final toggle = SegToggle( + options: const ['System', 'Light', 'Dark'], + index: idx, + onChanged: (i) { + final next = switch (i) { + 1 => AppThemeChoice.light, + 2 => AppThemeChoice.dark, + _ => AppThemeChoice.system, + }; + if (next == ctrl.choice) return; + // Cross-fade the whole app from the old look to the new one. + final overlay = themeSwitchKey.currentState; + if (overlay != null) { + overlay.run(() => ctrl.setChoice(next)); + } else { + ctrl.setChoice(next); + } + }, + ); + if (!labeled) return toggle; + final desc = ctrl.choice == AppThemeChoice.system + ? 'Following your phone — ${ctrl.isDark ? 'Ember on Char' : 'Ember on Paper'}' + : 'Ember on ${ctrl.isDark ? 'Char' : 'Paper'}'; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('APPEARANCE', style: AppText.overline), + const SizedBox(height: Sp.x3), + Align(alignment: Alignment.centerLeft, child: toggle), + const SizedBox(height: Sp.x2), + Text(desc, style: AppText.captionMuted), + ], + ); + } +} + /// Tiny confidence dot (honesty system). class ConfDot extends StatelessWidget { final double confidence; @@ -326,41 +398,53 @@ class ConfDot extends StatelessWidget { /// Small honesty label pill (EST. / BETA / REL.). class Tag extends StatelessWidget { final String text; - final Color color; - const Tag(this.text, {super.key, this.color = AppColors.warn}); + final Color? color; + const Tag(this.text, {super.key, this.color}); @override - Widget build(BuildContext context) => Container( - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(R.pill), - ), - child: Text(text.toUpperCase(), - style: AppText.overline - .copyWith(color: color, fontSize: 9.5, letterSpacing: 0.8)), - ); + Widget build(BuildContext context) { + final c = color ?? AppColors.warn; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: c.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(R.pill), + ), + child: Text(text.toUpperCase(), + style: AppText.overline + .copyWith(color: c, fontSize: 9.5, letterSpacing: 0.8)), + ); + } + static Widget? forMetric(Metric m) { - if (m.isEstimate) return const Tag('est', color: AppColors.warn); - if (m.isRelative) return const Tag('rel', color: AppColors.loadDetraining); - if (m.beta) return const Tag('beta', color: AppColors.coral); + if (m.isEstimate) return const Tag('est'); + if (m.isRelative) return _RelTag(); + if (m.beta) return _BetaTag(); return null; } } +/// Honesty tags whose colour is mode-resolved (can't be a const Tag arg). +class _RelTag extends StatelessWidget { + @override + Widget build(BuildContext context) => + Tag('rel', color: AppColors.loadDetraining); +} + +class _BetaTag extends StatelessWidget { + @override + Widget build(BuildContext context) => Tag('beta', color: AppColors.coral); +} + /// Compact round icon button (top-bar actions, like the ref's circular buttons). class RoundIconButton extends StatelessWidget { final IconData icon; final VoidCallback? onTap; - final Color bg; - final Color fg; - const RoundIconButton(this.icon, - {super.key, - this.onTap, - this.bg = AppColors.surface, - this.fg = AppColors.ink}); + final Color? bg; + final Color? fg; + const RoundIconButton(this.icon, {super.key, this.onTap, this.bg, this.fg}); @override Widget build(BuildContext context) => Material( - color: bg, + color: bg ?? AppColors.surface, shape: const CircleBorder(), elevation: 0, child: InkWell( @@ -368,7 +452,7 @@ class RoundIconButton extends StatelessWidget { onTap: onTap, child: Padding( padding: const EdgeInsets.all(Sp.x3), - child: AppIcon(icon, size: 20, color: fg), + child: AppIcon(icon, size: 20, color: fg ?? AppColors.ink), ), ), ); @@ -406,7 +490,7 @@ class DetailRow extends StatelessWidget { if (trailing != null) ...[const SizedBox(width: Sp.x2), trailing!] else if (onTap != null) ...[ const SizedBox(width: Sp.x2), - const AppIcon(Ic.arrowRight, size: 16, color: AppColors.inkMuted), + AppIcon(Ic.arrowRight, size: 16, color: AppColors.inkMuted), ], ]), ), @@ -442,7 +526,7 @@ class DetailRetentionNote extends StatelessWidget { @override Widget build(BuildContext context) => ProCard( child: Row(children: [ - const AppIcon(Ic.clock, size: 20, color: AppColors.inkMuted), + AppIcon(Ic.clock, size: 20, color: AppColors.inkMuted), const SizedBox(width: Sp.x3), Expanded( child: Text( diff --git a/lib/ui/notifications/notifications_screen.dart b/lib/ui/notifications/notifications_screen.dart index 4c9fea3..f793376 100644 --- a/lib/ui/notifications/notifications_screen.dart +++ b/lib/ui/notifications/notifications_screen.dart @@ -155,7 +155,7 @@ class _NotificationsScreenState extends State { style: AppText.title.copyWith( color: n.read ? AppColors.inkSoft : AppColors.ink))), if (!n.read) - Container(width: 8, height: 8, decoration: const BoxDecoration( + Container(width: 8, height: 8, decoration: BoxDecoration( color: AppColors.coral, shape: BoxShape.circle)), ]), const SizedBox(height: 4), @@ -167,7 +167,7 @@ class _NotificationsScreenState extends State { } Widget _honesty() => Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - const AppIcon(Ic.shield, size: 14, color: AppColors.inkMuted), + AppIcon(Ic.shield, size: 14, color: AppColors.inkMuted), const SizedBox(width: Sp.x2), Expanded(child: Text( 'Built from your own data with simple rules. ' @@ -176,8 +176,8 @@ class _NotificationsScreenState extends State { )), ]); - Widget _loading() => const ProCard( - padding: EdgeInsets.all(Sp.x6), + Widget _loading() => ProCard( + padding: const EdgeInsets.all(Sp.x6), child: SizedBox(height: 280, child: Center(child: CircularProgressIndicator(color: AppColors.coral))), ); @@ -187,7 +187,7 @@ class _NotificationsScreenState extends State { child: Column(children: [ Container( padding: const EdgeInsets.all(Sp.x4), - decoration: const BoxDecoration(color: AppColors.coralSoft, shape: BoxShape.circle), + decoration: BoxDecoration(color: AppColors.coralSoft, shape: BoxShape.circle), child: AppIcon(icon, size: 30, color: AppColors.coralDeep), ), const SizedBox(height: Sp.x4), diff --git a/lib/ui/onboarding_screens.dart b/lib/ui/onboarding_screens.dart index 63004e0..7969f8b 100644 --- a/lib/ui/onboarding_screens.dart +++ b/lib/ui/onboarding_screens.dart @@ -8,6 +8,7 @@ import '../net/api_client.dart'; import '../state/app_state.dart'; import '../sync/config.dart'; import '../theme/theme.dart'; +import '../theme/theme_switcher.dart'; import '../theme/tokens.dart'; import 'kit/kit.dart'; @@ -40,7 +41,7 @@ class _ErrorLine extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const AppIcon(Ic.info, size: 18, color: AppColors.bad), + AppIcon(Ic.info, size: 18, color: AppColors.bad), const SizedBox(width: Sp.x2), Expanded( child: Text(message, @@ -113,7 +114,7 @@ class _BackendChoiceScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ - const AppIcon(Ic.shield, size: 22, color: AppColors.coralDeep), + AppIcon(Ic.shield, size: 22, color: AppColors.coralDeep), const SizedBox(width: Sp.x2), Text('Choose carefully', style: AppText.h2), ]), @@ -135,7 +136,7 @@ class _BackendChoiceScreenState extends State { padding: const EdgeInsets.symmetric( horizontal: Sp.x4, vertical: Sp.x1), child: Row(children: [ - const AppIcon(Ic.cloud, size: 20, color: AppColors.inkSoft), + AppIcon(Ic.cloud, size: 20, color: AppColors.inkSoft), const SizedBox(width: Sp.x3), Expanded( child: TextField( @@ -232,8 +233,7 @@ class _AuthScreenState extends State { resp = await app.api!.requestOtp(email); } if (!mounted) return; - Navigator.of(context).push(MaterialPageRoute( - builder: (_) => + Navigator.of(context).push(themedRoute((_) => OtpScreen(email: email, devCode: resp['dev_code'] as String?), )); } on ApiException catch (e) { @@ -400,7 +400,7 @@ class _OtpScreenState extends State { if (widget.devCode != null) ...[ const SizedBox(height: Sp.x3), Row(children: [ - const Tag('dev', color: AppColors.coral), + Tag('dev', color: AppColors.coral), const SizedBox(width: Sp.x2), Expanded( child: Text('Code prefilled — no email key configured.', @@ -598,6 +598,10 @@ class _ProfileSetupScreenState extends State { max: 200, onChanged: (v) => setState(() => _weightKg = v.round()), ), + const SizedBox(height: Sp.x6), + + // Appearance — defaults to your phone's mode; change it live here. + const _AppearanceSelector(), if (_error != null) _ErrorLine(_error!), const SizedBox(height: Sp.x7), @@ -622,6 +626,13 @@ class _ProfileSetupScreenState extends State { } } +/// Inline appearance section for onboarding (matches the SEX/AGE section style). +class _AppearanceSelector extends StatelessWidget { + const _AppearanceSelector(); + @override + Widget build(BuildContext context) => const AppearanceSelector(labeled: true); +} + /// A selectable pill for the sex segmented control. class _SexPill extends StatelessWidget { final String label; diff --git a/lib/ui/pairing_screen.dart b/lib/ui/pairing_screen.dart index d32c77a..59c1f93 100644 --- a/lib/ui/pairing_screen.dart +++ b/lib/ui/pairing_screen.dart @@ -88,7 +88,7 @@ class _InstructionStep extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const AppIcon(Ic.info, size: 20, color: AppColors.warn), + AppIcon(Ic.info, size: 20, color: AppColors.warn), const SizedBox(width: Sp.x3), Expanded( child: Text( @@ -302,7 +302,7 @@ class _ScanStepState extends State<_ScanStep> { const Spacer(), if (_error != null) ...[ Row(children: [ - const AppIcon(Ic.info, size: 18, color: AppColors.bad), + AppIcon(Ic.info, size: 18, color: AppColors.bad), const SizedBox(width: Sp.x2), Expanded( child: Text(_error!, @@ -394,7 +394,7 @@ class _VisualState extends State<_Visual> color: AppColors.coralSoft, borderRadius: BorderRadius.circular(R.cardSm), ), - child: const AppIcon(Ic.watch, size: 34, color: AppColors.coralDeep), + child: AppIcon(Ic.watch, size: 34, color: AppColors.coralDeep), ), const SizedBox(height: Sp.x4), Text(widget.name(widget.device!), @@ -409,9 +409,9 @@ class _VisualState extends State<_Visual> child: Container( width: 132, height: 132, - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.surfaceAlt, shape: BoxShape.circle), - child: const AppIcon(Ic.watch, size: 56, color: AppColors.inkMuted), + child: AppIcon(Ic.watch, size: 56, color: AppColors.inkMuted), ), ); } diff --git a/lib/ui/profile/profile_screen.dart b/lib/ui/profile/profile_screen.dart index a50008d..54fc867 100644 --- a/lib/ui/profile/profile_screen.dart +++ b/lib/ui/profile/profile_screen.dart @@ -6,6 +6,7 @@ import 'package:provider/provider.dart'; import '../../state/app_state.dart'; import '../../theme/theme.dart'; +import '../../theme/theme_switcher.dart'; import '../../theme/tokens.dart'; import '../kit/kit.dart'; import '../today/step_goal_screen.dart'; @@ -112,8 +113,7 @@ class ProfileScreen extends StatelessWidget { ? '${user['step_goal']} steps' : 'Set', onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => StepGoalScreen( + themedRoute((_) => StepGoalScreen( goal: (user['step_goal'] as num?)?.toInt(), ), ), @@ -123,6 +123,14 @@ class ProfileScreen extends StatelessWidget { const SizedBox(height: Sp.x7), + // ── Appearance ─────────────────────────────────────────────── + const SectionHeader('Appearance'), + ProCard( + child: const AppearanceSelector(labeled: true), + ), + + const SizedBox(height: Sp.x7), + // ── Backend ────────────────────────────────────────────────── const SectionHeader('Backend'), ProCard( @@ -136,7 +144,7 @@ class ProfileScreen extends StatelessWidget { color: AppColors.surfaceAlt, borderRadius: BorderRadius.circular(R.chip), ), - child: const AppIcon(Ic.server, + child: AppIcon(Ic.server, size: 20, color: AppColors.inkSoft), ), const SizedBox(width: Sp.x3), @@ -176,7 +184,7 @@ class ProfileScreen extends StatelessWidget { label: 'Sign out', value: '', onTap: () => _confirmSignOut(context, app), - trailing: const AppIcon(Ic.arrowRight, + trailing: AppIcon(Ic.arrowRight, size: 16, color: AppColors.coral), ), ), @@ -191,7 +199,7 @@ class ProfileScreen extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ - const AppIcon(Ic.shield, size: 18, color: AppColors.inkSoft), + AppIcon(Ic.shield, size: 18, color: AppColors.inkSoft), const SizedBox(width: Sp.x2), Text('How your metrics are made', style: AppText.label), ]), @@ -329,7 +337,7 @@ class _DeviceHero extends StatelessWidget { if (app.paired == null) { return ProCard( child: Row(children: [ - const AppIcon(Ic.watch, size: 22, color: AppColors.inkMuted), + AppIcon(Ic.watch, size: 22, color: AppColors.inkMuted), const SizedBox(width: Sp.x3), Expanded( child: Text('No strap paired.', style: AppText.bodySoft)), @@ -518,7 +526,7 @@ class _DeviceSheet extends StatelessWidget { color: AppColors.bad.withValues(alpha: 0.5), width: 1.5), ), onPressed: () => _forget(context, live), - icon: const AppIcon(Ic.cancel, size: 18, color: AppColors.bad), + icon: AppIcon(Ic.cancel, size: 18, color: AppColors.bad), label: const Text('Forget device'), ), ), @@ -789,7 +797,7 @@ class _Notice extends StatelessWidget { borderRadius: BorderRadius.circular(R.chip), ), child: Row(children: [ - const AppIcon(Ic.info, size: 18, color: AppColors.warn), + AppIcon(Ic.info, size: 18, color: AppColors.warn), const SizedBox(width: Sp.x2), Expanded(child: Text(text, style: AppText.caption)), ]), @@ -800,7 +808,7 @@ class _HairDivider extends StatelessWidget { const _HairDivider(); @override Widget build(BuildContext context) => - const Divider(height: 1, thickness: 1, color: AppColors.divider); + Divider(height: 1, thickness: 1, color: AppColors.divider); } void _snack(BuildContext context, String msg) { diff --git a/lib/ui/recap/recap_screen.dart b/lib/ui/recap/recap_screen.dart index 748622a..4911e65 100644 --- a/lib/ui/recap/recap_screen.dart +++ b/lib/ui/recap/recap_screen.dart @@ -269,7 +269,7 @@ class _RecapScreenState extends State { color: AppColors.coralSoft, borderRadius: BorderRadius.circular(R.chip), ), - child: const AppIcon(Ic.strain, + child: AppIcon(Ic.strain, size: 16, color: AppColors.coralDeep), ), const SizedBox(width: Sp.x2), @@ -333,7 +333,7 @@ class _RecapScreenState extends State { // Footer. Row( children: [ - const AppIcon(Ic.shield, + AppIcon(Ic.shield, size: 13, color: AppColors.inkMuted), const SizedBox(width: 6), Text('your data · your server · openstrap', @@ -448,8 +448,8 @@ class _RecapScreenState extends State { // ── states ───────────────────────────────────────────────────────────────── - Widget _loading() => const ProCard( - padding: EdgeInsets.all(Sp.x6), + Widget _loading() => ProCard( + padding: const EdgeInsets.all(Sp.x6), child: SizedBox( height: 360, child: Center( @@ -465,7 +465,7 @@ class _RecapScreenState extends State { children: [ Container( padding: const EdgeInsets.all(Sp.x4), - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.coralSoft, shape: BoxShape.circle, ), diff --git a/lib/ui/records/records_screen.dart b/lib/ui/records/records_screen.dart index 3d24251..fc7ad87 100644 --- a/lib/ui/records/records_screen.dart +++ b/lib/ui/records/records_screen.dart @@ -312,8 +312,8 @@ class _RecordsScreenState extends State { : s.split(RegExp(r'[ _/]')).where((w) => w.isNotEmpty) .map((w) => '${w[0].toUpperCase()}${w.substring(1)}').join(' '); - Widget _loading() => const ProCard( - padding: EdgeInsets.all(Sp.x6), + Widget _loading() => ProCard( + padding: const EdgeInsets.all(Sp.x6), child: SizedBox(height: 320, child: Center(child: CircularProgressIndicator(color: AppColors.coral))), ); @@ -323,7 +323,7 @@ class _RecordsScreenState extends State { child: Column(children: [ Container( padding: const EdgeInsets.all(Sp.x4), - decoration: const BoxDecoration(color: AppColors.coralSoft, shape: BoxShape.circle), + decoration: BoxDecoration(color: AppColors.coralSoft, shape: BoxShape.circle), child: AppIcon(icon, size: 30, color: AppColors.coralDeep), ), const SizedBox(height: Sp.x4), diff --git a/lib/ui/screens/detail_cards.dart b/lib/ui/screens/detail_cards.dart index 82dd411..61547de 100644 --- a/lib/ui/screens/detail_cards.dart +++ b/lib/ui/screens/detail_cards.dart @@ -59,7 +59,7 @@ class _FetchState extends State<_Fetch> { } // Zone palette reused across metrics. -const _zoneColors = [AppColors.cool, AppColors.loadDetraining, AppColors.good, AppColors.warn, AppColors.coral]; +final _zoneColors = [AppColors.cool, AppColors.loadDetraining, AppColors.good, AppColors.warn, AppColors.coral]; // ── HEART ──────────────────────────────────────────────────────────────────── class HeartDayCard extends StatelessWidget { @@ -106,7 +106,7 @@ class HeartDayCard extends StatelessWidget { AppIcon(rec != null ? Ic.recovery : Ic.heart, size: 16, color: AppColors.coralDeep), const SizedBox(width: Sp.x2), Text(rec != null ? 'RECOVERY' : 'RESTING HR', style: AppText.overline), - if (rec != null) ...[const SizedBox(width: Sp.x2), const Tag('HRV', color: AppColors.good)], + if (rec != null) ...[const SizedBox(width: Sp.x2), Tag('HRV', color: AppColors.good)], ]), const SizedBox(height: Sp.x3), if (rec != null) @@ -286,7 +286,7 @@ class _IllnessCard extends StatelessWidget { Container( padding: const EdgeInsets.all(9), decoration: BoxDecoration(color: AppColors.surfaceSunk, borderRadius: BorderRadius.circular(R.chip)), - child: const AppIcon(Ic.info, size: 17, color: AppColors.inkMuted), + child: AppIcon(Ic.info, size: 17, color: AppColors.inkMuted), ), const SizedBox(width: Sp.x3), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -326,7 +326,7 @@ class _IllnessCard extends StatelessWidget { ])), // Per-feature deviations (what's moving), when present. if (drivers.isNotEmpty) ...[ - const Divider(height: 1, color: AppColors.divider), + Divider(height: 1, color: AppColors.divider), Padding(padding: const EdgeInsets.symmetric(horizontal: Sp.x4, vertical: Sp.x2), child: Column( children: [ for (final dr in drivers) @@ -367,7 +367,7 @@ class _IrregularCard extends StatelessWidget { ])), ])), if (irr['sd1'] != null && irr['sd2'] != null) ...[ - const Divider(height: 1, color: AppColors.divider), + Divider(height: 1, color: AppColors.divider), Padding(padding: const EdgeInsets.symmetric(horizontal: Sp.x4, vertical: Sp.x2), child: Column(children: [ DetailRow(label: 'Poincaré SD1 / SD2', value: '${irr['sd1']} / ${irr['sd2']} ms'), if (irr['pnn50'] != null) DetailRow(label: 'pNN50', value: '${irr['pnn50']}%'), @@ -424,11 +424,11 @@ class WearDayCard extends StatelessWidget { child: Row(children: [ Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row(children: [ - const AppIcon(Ic.watch, size: 16, color: AppColors.coralDeep), + AppIcon(Ic.watch, size: 16, color: AppColors.coralDeep), const SizedBox(width: Sp.x2), Text('TIME WORN', style: AppText.overline), const SizedBox(width: Sp.x2), - const Tag('AUTH', color: AppColors.good), + Tag('AUTH', color: AppColors.good), ]), const SizedBox(height: Sp.x3), Text(hm(worn), style: AppText.display), diff --git a/lib/ui/screens/metric_row.dart b/lib/ui/screens/metric_row.dart index e6f2110..d0c08ed 100644 --- a/lib/ui/screens/metric_row.dart +++ b/lib/ui/screens/metric_row.dart @@ -50,7 +50,7 @@ String? infoFor(String key) => kMetricInfo[key]; /// A single metric line: [icon chip] label + description ........ value unit [›] class MetricRow extends StatelessWidget { final IconData icon; - final Color accent; + final Color? accent; final String label; final String? info; final String value; @@ -64,12 +64,13 @@ class MetricRow extends StatelessWidget { required this.value, this.info, this.unit, - this.accent = AppColors.coral, + this.accent, this.valueTag, this.onTap, }); @override Widget build(BuildContext context) { + final accent = this.accent ?? AppColors.coral; final row = Padding( padding: const EdgeInsets.symmetric(vertical: Sp.x3), child: Column( @@ -105,7 +106,7 @@ class MetricRow extends StatelessWidget { if (valueTag != null) ...[const SizedBox(width: Sp.x2), valueTag!], if (onTap != null) ...[ const SizedBox(width: Sp.x2), - const AppIcon(Ic.arrowRight, size: 16, color: AppColors.inkMuted), + AppIcon(Ic.arrowRight, size: 16, color: AppColors.inkMuted), ], ], ), @@ -136,7 +137,7 @@ class MetricGroup extends StatelessWidget { for (int i = 0; i < rows.length; i++) { children.add(rows[i]); if (i < rows.length - 1) { - children.add(const Divider(height: 1, thickness: 1, color: AppColors.divider)); + children.add(Divider(height: 1, thickness: 1, color: AppColors.divider)); } } return ProCard(child: Column(children: children)); diff --git a/lib/ui/screens/metric_screen.dart b/lib/ui/screens/metric_screen.dart index 8030bb0..d30a9b6 100644 --- a/lib/ui/screens/metric_screen.dart +++ b/lib/ui/screens/metric_screen.dart @@ -198,8 +198,8 @@ class _DrillLevelState extends State<_DrillLevel> { @override Widget build(BuildContext context) { if (_loading) { - return const ProCard( - padding: EdgeInsets.all(Sp.x6), + return ProCard( + padding: const EdgeInsets.all(Sp.x6), child: SizedBox(height: 200, child: Center( child: SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.coral)))), diff --git a/lib/ui/screens/trend_screen.dart b/lib/ui/screens/trend_screen.dart index 89448b1..3203245 100644 --- a/lib/ui/screens/trend_screen.dart +++ b/lib/ui/screens/trend_screen.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../state/app_state.dart'; import '../../theme/theme.dart'; +import '../../theme/theme_switcher.dart'; import '../../theme/tokens.dart'; import '../kit/kit.dart'; import 'metric_screen.dart'; @@ -18,11 +19,10 @@ void openTrend( required String title, required String metric, required IconData icon, - Color accent = AppColors.coral, + Color? accent, String Function(double v)? valueFmt, }) { - Navigator.of(context).push(MaterialPageRoute( - builder: (_) => GenericTrendScreen( + Navigator.of(context).push(themedRoute((_) => GenericTrendScreen( title: title, metric: metric, icon: icon, accent: accent, valueFmt: valueFmt), )); } @@ -34,27 +34,30 @@ class GenericTrendScreen extends StatelessWidget { final String title; final String metric; final IconData icon; - final Color accent; + final Color? accent; final String Function(double v)? valueFmt; const GenericTrendScreen({ super.key, required this.title, required this.metric, required this.icon, - this.accent = AppColors.coral, + this.accent, this.valueFmt, }); @override - Widget build(BuildContext context) => MetricScreen( - title: title, - metric: metric, - icon: icon, - accent: accent, - valueFmt: valueFmt, - todayDetail: (ctx) => _TrendTodayCard(metric: metric, icon: icon, accent: accent, valueFmt: valueFmt), - dayDetail: (ctx, date) => _TrendTodayCard(metric: metric, icon: icon, accent: accent, valueFmt: valueFmt), - ); + Widget build(BuildContext context) { + final accent = this.accent ?? AppColors.coral; + return MetricScreen( + title: title, + metric: metric, + icon: icon, + accent: accent, + valueFmt: valueFmt, + todayDetail: (ctx) => _TrendTodayCard(metric: metric, icon: icon, accent: accent, valueFmt: valueFmt), + dayDetail: (ctx, date) => _TrendTodayCard(metric: metric, icon: icon, accent: accent, valueFmt: valueFmt), + ); + } } /// Compact "current value + change + what-this-is" leaf for a generic metric. @@ -137,7 +140,7 @@ class _TrendTodayCardState extends State<_TrendTodayCard> { /// look matches every other row; the chevron signals it's drillable. class TrendMetricRow extends StatelessWidget { final IconData icon; - final Color accent; + final Color? accent; final String label; final String? info; final String value; @@ -155,7 +158,7 @@ class TrendMetricRow extends StatelessWidget { required this.trendTitle, this.info, this.unit, - this.accent = AppColors.coral, + this.accent, this.valueTag, this.valueFmt, }); diff --git a/lib/ui/sleep/sleep_detail_screen.dart b/lib/ui/sleep/sleep_detail_screen.dart index a929dc1..005c433 100644 --- a/lib/ui/sleep/sleep_detail_screen.dart +++ b/lib/ui/sleep/sleep_detail_screen.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import '../../net/api_client.dart'; import '../../state/app_state.dart'; import '../../theme/theme.dart'; +import '../../theme/theme_switcher.dart'; import '../../theme/tokens.dart'; import '../kit/kit.dart'; import '../kit/charts.dart'; @@ -340,8 +341,7 @@ class _SleepDetailScreenState extends State { ), // All sleeps of the day (naps included) — the v2 multi-period view. RoundIconButton(Ic.bed, onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => SleepPeriodsScreen(date: widget.date), + themedRoute((_) => SleepPeriodsScreen(date: widget.date), ), )), ], @@ -368,12 +368,12 @@ class _SleepDetailScreenState extends State { children: [ Row( children: [ - const AppIcon(Ic.moon, size: 16, color: AppColors.coralDeep), + AppIcon(Ic.moon, size: 16, color: AppColors.coralDeep), const SizedBox(width: Sp.x2), Text('TIME ASLEEP', style: AppText.overline), if (_stagesBeta) ...[ const SizedBox(width: Sp.x2), - const Tag('beta', color: AppColors.coral), + Tag('beta', color: AppColors.coral), ], ], ), @@ -426,7 +426,7 @@ class _SleepDetailScreenState extends State { children: [ Text('Sleep stages', style: AppText.h2), const Spacer(), - const AppIcon(Ic.clock, size: 16, color: AppColors.inkMuted), + AppIcon(Ic.clock, size: 16, color: AppColors.inkMuted), ], ), const SizedBox(height: Sp.x4), @@ -736,12 +736,12 @@ class _SleepDetailScreenState extends State { if (respVal != null) ...[ const SizedBox(height: Sp.x4), Row(children: [ - const AppIcon(Ic.activity, size: 16, color: AppColors.coralDeep), + AppIcon(Ic.activity, size: 16, color: AppColors.coralDeep), const SizedBox(width: Sp.x2), Text('${respVal.toStringAsFixed(1)} breaths/min', style: AppText.title), const SizedBox(width: Sp.x2), - const Tag('beta', color: AppColors.coral), + Tag('beta', color: AppColors.coral), ]), ], if (elevated) ...[ @@ -751,7 +751,7 @@ class _SleepDetailScreenState extends State { decoration: BoxDecoration( color: AppColors.warnSoft, borderRadius: BorderRadius.circular(R.cardSm)), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - const AppIcon(Ic.info, size: 16, color: AppColors.warn), + AppIcon(Ic.info, size: 16, color: AppColors.warn), const SizedBox(width: Sp.x2), Expanded(child: Text( 'Overnight heart rate ran above your baseline — often an early cue of ' @@ -784,8 +784,8 @@ class _SleepDetailScreenState extends State { // ── states ─────────────────────────────────────────────────────────────────── - Widget _loading() => const ProCard( - padding: EdgeInsets.all(Sp.x6), + Widget _loading() => ProCard( + padding: const EdgeInsets.all(Sp.x6), child: SizedBox( height: 360, child: Center(child: CircularProgressIndicator(color: AppColors.coral)), @@ -799,7 +799,7 @@ class _SleepDetailScreenState extends State { children: [ Container( padding: const EdgeInsets.all(Sp.x4), - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.coralSoft, shape: BoxShape.circle, ), diff --git a/lib/ui/sleep/sleep_periods_screen.dart b/lib/ui/sleep/sleep_periods_screen.dart index f435a39..764ed4b 100644 --- a/lib/ui/sleep/sleep_periods_screen.dart +++ b/lib/ui/sleep/sleep_periods_screen.dart @@ -128,7 +128,7 @@ class _SleepPeriodsScreenState extends State { ], ), ), - const AppIcon(Ic.moon, size: 30, color: AppColors.coral), + AppIcon(Ic.moon, size: 30, color: AppColors.coral), ], ), ); @@ -154,7 +154,7 @@ class _SleepPeriodsScreenState extends State { const SizedBox(width: Sp.x2), Text(isMain ? 'Main sleep' : 'Nap', style: AppText.h2), const SizedBox(width: Sp.x2), - if (_beta) const Tag('beta', color: AppColors.coral), + if (_beta) Tag('beta', color: AppColors.coral), const Spacer(), ConfDot(conf), ], diff --git a/lib/ui/stress/stress_screen.dart b/lib/ui/stress/stress_screen.dart index 490d841..a1270be 100644 --- a/lib/ui/stress/stress_screen.dart +++ b/lib/ui/stress/stress_screen.dart @@ -212,11 +212,11 @@ class _StressScreenState extends State { mainAxisSize: MainAxisSize.min, children: [ Row(children: [ - const AppIcon(Ic.pulse, size: 16, color: AppColors.coralDeep), + AppIcon(Ic.pulse, size: 16, color: AppColors.coralDeep), const SizedBox(width: Sp.x2), Text('DAY STRESS', style: AppText.overline), const SizedBox(width: Sp.x2), - const Tag('est.', color: AppColors.coral), + Tag('est.', color: AppColors.coral), ]), const SizedBox(height: Sp.x3), Text('$v', style: AppText.display.copyWith(color: color)), @@ -256,7 +256,7 @@ class _StressScreenState extends State { ), ), const SizedBox(height: Sp.x2), - Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: const [ + Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('12a', style: TextStyle(fontSize: 10, color: AppColors.inkMuted)), Text('6a', style: TextStyle(fontSize: 10, color: AppColors.inkMuted)), Text('12p', style: TextStyle(fontSize: 10, color: AppColors.inkMuted)), @@ -331,7 +331,7 @@ class _StressScreenState extends State { padding: const EdgeInsets.all(Sp.x3), decoration: BoxDecoration( color: AppColors.warnSoft, borderRadius: BorderRadius.circular(R.chip)), - child: const AppIcon(Ic.pulse, size: 20, color: AppColors.warn), + child: AppIcon(Ic.pulse, size: 20, color: AppColors.warn), ), const SizedBox(width: Sp.x4), Expanded(child: Column( @@ -349,7 +349,7 @@ class _StressScreenState extends State { } Widget _honesty() => Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - const AppIcon(Ic.info, size: 14, color: AppColors.inkMuted), + AppIcon(Ic.info, size: 14, color: AppColors.inkMuted), const SizedBox(width: Sp.x2), Expanded(child: Text( 'An arousal estimate — heart rate above your resting level while you\'re ' @@ -359,8 +359,8 @@ class _StressScreenState extends State { )), ]); - Widget _loading() => const ProCard( - padding: EdgeInsets.all(Sp.x6), + Widget _loading() => ProCard( + padding: const EdgeInsets.all(Sp.x6), child: SizedBox(height: 320, child: Center(child: CircularProgressIndicator(color: AppColors.coral))), ); @@ -370,7 +370,7 @@ class _StressScreenState extends State { child: Column(children: [ Container( padding: const EdgeInsets.all(Sp.x4), - decoration: const BoxDecoration(color: AppColors.coralSoft, shape: BoxShape.circle), + decoration: BoxDecoration(color: AppColors.coralSoft, shape: BoxShape.circle), child: AppIcon(icon, size: 30, color: AppColors.coralDeep), ), const SizedBox(height: Sp.x4), diff --git a/lib/ui/today/step_goal_screen.dart b/lib/ui/today/step_goal_screen.dart index 79da78a..e179a19 100644 --- a/lib/ui/today/step_goal_screen.dart +++ b/lib/ui/today/step_goal_screen.dart @@ -107,7 +107,7 @@ class _StepGoalScreenState extends State { const SizedBox(height: Sp.x5), Center( child: reached - ? const Tag('goal reached', color: AppColors.good) + ? Tag('goal reached', color: AppColors.good) : Text('$remaining to go', style: AppText.label.copyWith(color: AppColors.inkSoft)), ), diff --git a/lib/ui/today/today_screen.dart b/lib/ui/today/today_screen.dart index 6a7c310..d989033 100644 --- a/lib/ui/today/today_screen.dart +++ b/lib/ui/today/today_screen.dart @@ -8,6 +8,7 @@ import '../../models/payloads.dart'; import '../../net/api_client.dart'; import '../../state/app_state.dart'; import '../../theme/theme.dart'; +import '../../theme/theme_switcher.dart'; import '../../theme/tokens.dart'; import '../kit/kit.dart'; import '../kit/charts.dart'; @@ -175,18 +176,18 @@ class _TodayScreenState extends State const SizedBox(width: Sp.x3), _bellButton(), const SizedBox(width: Sp.x2), - RoundIconButton(Ic.edit, onTap: () => _push(const JournalScreen())), + RoundIconButton(Ic.edit, onTap: () => _push(() => const JournalScreen())), const SizedBox(width: Sp.x2), // Profile / settings (the old "You" tab moved here). ProfileScreen is tab // content (no Scaffold of its own), so wrap it when pushing standalone — // otherwise it renders with no Material (black bg + yellow-underlined text). - RoundIconButton(Ic.profile, onTap: () => _push( - const Scaffold(backgroundColor: AppColors.bg, body: ProfileScreen()))), + RoundIconButton(Ic.profile, onTap: () => _push(() => + Scaffold(backgroundColor: AppColors.bg, body: const ProfileScreen()))), const SizedBox(width: Sp.x2), RoundIconButton(Ic.chart, bg: AppColors.coral, fg: Colors.white, - onTap: () => _push(const RecapScreen())), + onTap: () => _push(() => const RecapScreen())), ], ); } @@ -198,7 +199,7 @@ class _TodayScreenState extends State children: [ RoundIconButton(Ic.bell, onTap: () async { await Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const NotificationsScreen())); + themedRoute((_) => const NotificationsScreen())); if (!mounted) return; try { final n = await context.read().api?.getNotifications(); @@ -229,13 +230,15 @@ class _TodayScreenState extends State ); } - void _push(Widget screen) => - Navigator.of(context).push(MaterialPageRoute(builder: (_) => screen)); + // Builder-based so themedRoute reconstructs the screen on a theme flip (a + // prebuilt instance would be returned unchanged and never re-colour). + void _push(Widget Function() build) => + Navigator.of(context).push(themedRoute((_) => build())); Widget _freshness(String label) { return Row( children: [ - const AppIcon(Ic.cloud, size: 14, color: AppColors.inkMuted), + AppIcon(Ic.cloud, size: 14, color: AppColors.inkMuted), const SizedBox(width: Sp.x2), Text('Showing cached • $label', style: AppText.captionMuted), @@ -302,7 +305,7 @@ class _TodayScreenState extends State accent: AppColors.good, confidence: t.steps.isEmpty ? null : t.steps.confidence, tag: Tag.forMetric(t.steps), - onTap: () => _push(StepGoalScreen( + onTap: () => _push(() => StepGoalScreen( steps: t.steps.isEmpty ? null : t.steps.value!.round(), goal: t.stepGoal, )), @@ -313,7 +316,7 @@ class _TodayScreenState extends State value: _hm(t.wearTime), accent: AppColors.coralDeep, confidence: t.wearTime.isEmpty ? null : t.wearTime.confidence, - onTap: () => _push(const WearScreen()), + onTap: () => _push(() => const WearScreen()), ), ), const SizedBox(height: Sp.x3), @@ -324,8 +327,8 @@ class _TodayScreenState extends State value: t.stress?.score?.toString(), unit: '/100', accent: AppColors.warn, - tag: const Tag('est.', color: AppColors.coral), - onTap: () => _push(StressScreen(date: date)), + tag: Tag('est.', color: AppColors.coral), + onTap: () => _push(() => StressScreen(date: date)), ), // HRV (measured, beat-to-beat). The real one now that we decode R-R intervals. StatTile( @@ -335,7 +338,7 @@ class _TodayScreenState extends State unit: 'ms', accent: AppColors.good, confidence: t.hrv?.confidence, - tag: const Tag('beta', color: AppColors.coral), + tag: Tag('beta', color: AppColors.coral), ), ), const SizedBox(height: Sp.x3), @@ -348,7 +351,7 @@ class _TodayScreenState extends State : (t.spo2Idx! >= 0 ? '+' : '') + t.spo2Idx!.toStringAsFixed(0), unit: 'Δ', accent: AppColors.coralDeep, - tag: const Tag('beta', color: AppColors.coral), + tag: Tag('beta', color: AppColors.coral), ), _bodyOverTimeTile(), ), @@ -377,7 +380,7 @@ class _TodayScreenState extends State Container( padding: const EdgeInsets.all(Sp.x3), decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.6), + color: AppColors.surface.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(R.chip), ), child: AppIcon(overtrain ? Ic.strain : Ic.heart, @@ -404,7 +407,7 @@ class _TodayScreenState extends State final top = coach.plan.isNotEmpty ? coach.plan.first : null; final tgt = coach.strainTarget; return ProCard( - onTap: () => _push(CoachScreen(coach: coach)), + onTap: () => _push(() => CoachScreen(coach: coach)), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -413,7 +416,7 @@ class _TodayScreenState extends State padding: const EdgeInsets.all(7), decoration: BoxDecoration( color: AppColors.coralSoft, borderRadius: BorderRadius.circular(R.chip)), - child: const AppIcon(Ic.recovery, size: 17, color: AppColors.coralDeep), + child: AppIcon(Ic.recovery, size: 17, color: AppColors.coralDeep), ), const SizedBox(width: Sp.x2), Expanded(child: Text("Today's plan", style: AppText.h2)), @@ -421,7 +424,7 @@ class _TodayScreenState extends State Text('strain ~${tgt.value.toStringAsFixed(0)}', style: AppText.label.copyWith(color: AppColors.coralDeep)), const SizedBox(width: 4), - const AppIcon(Ic.arrowRight, size: 16, color: AppColors.coralDeep), + AppIcon(Ic.arrowRight, size: 16, color: AppColors.coralDeep), ]), const SizedBox(height: Sp.x3), if (top != null) ...[ @@ -455,7 +458,7 @@ class _TodayScreenState extends State child: Row(children: [ Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row(children: [ - const AppIcon(Ic.recovery, size: 16, color: AppColors.coralDeep), + AppIcon(Ic.recovery, size: 16, color: AppColors.coralDeep), const SizedBox(width: Sp.x2), Text('READINESS', style: AppText.overline), ]), @@ -487,13 +490,13 @@ class _TodayScreenState extends State children: [ _gauge('STRAIN', t.strain.isEmpty ? null : t.strain.value!.toStringAsFixed(1), null, strainT, AppColors.coral, - onTap: () => _push(const BodyScreen())), + onTap: () => _push(() => const BodyScreen())), _gauge('SLEEP', t.sleepDuration.isEmpty ? null : (t.sleepDuration.value! / 60).toStringAsFixed(1), 'h', sleepT, AppColors.loadDetraining, - onTap: () => _push(const SleepScreen())), + onTap: () => _push(() => const SleepScreen())), _gauge('HRV', hrv == null ? null : hrv.rmssd.toStringAsFixed(0), 'ms', hrvT, AppColors.good, - onTap: () => _push(const HeartScreen())), + onTap: () => _push(() => const HeartScreen())), ], ), ); @@ -544,7 +547,7 @@ class _TodayScreenState extends State Widget _bodyOverTimeTile() => ConstrainedBox( constraints: const BoxConstraints(minHeight: 110), child: ProCard( - onTap: () => _push(const RecordsScreen()), + onTap: () => _push(() => const RecordsScreen()), padding: const EdgeInsets.all(Sp.x3), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -555,11 +558,11 @@ class _TodayScreenState extends State padding: const EdgeInsets.all(6), decoration: BoxDecoration( color: AppColors.coralSoft, borderRadius: BorderRadius.circular(R.chip)), - child: const AppIcon(Ic.recovery, size: 16, color: AppColors.coralDeep), + child: AppIcon(Ic.recovery, size: 16, color: AppColors.coralDeep), ), const SizedBox(width: Sp.x2), Expanded(child: Text('Your body', style: AppText.label)), - const AppIcon(Ic.arrowRight, size: 15, color: AppColors.coralDeep), + AppIcon(Ic.arrowRight, size: 15, color: AppColors.coralDeep), ]), Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -580,20 +583,20 @@ class _TodayScreenState extends State final values = _hr.points.map((p) => p.v).toList(); final hasData = values.length >= 2; return ProCard( - onTap: () => _push(JourneyScreen(date: _todayStr())), + onTap: () => _push(() => JourneyScreen(date: _todayStr())), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - const AppIcon(Ic.pulse, size: 19, color: AppColors.coral), + AppIcon(Ic.pulse, size: 19, color: AppColors.coral), const SizedBox(width: Sp.x2), Expanded( child: Text("Today's heart rate", style: AppText.h2), ), Text('Your day', style: AppText.label.copyWith(color: AppColors.coralDeep)), const SizedBox(width: 2), - const AppIcon(Ic.arrowRight, size: 15, color: AppColors.coralDeep), + AppIcon(Ic.arrowRight, size: 15, color: AppColors.coralDeep), ], ), const SizedBox(height: Sp.x4), @@ -606,7 +609,7 @@ class _TodayScreenState extends State child: Column( mainAxisSize: MainAxisSize.min, children: [ - const AppIcon(Ic.heart, size: 26, color: AppColors.inkMuted), + AppIcon(Ic.heart, size: 26, color: AppColors.inkMuted), const SizedBox(height: Sp.x2), Text('No heart-rate data yet today', style: AppText.captionMuted), @@ -628,11 +631,11 @@ class _TodayScreenState extends State children: [ Container( padding: const EdgeInsets.all(Sp.x4), - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.coralSoft, shape: BoxShape.circle, ), - child: const AppIcon(Ic.watch, size: 30, color: AppColors.coralDeep), + child: AppIcon(Ic.watch, size: 30, color: AppColors.coralDeep), ), const SizedBox(height: Sp.x4), Text(title, style: AppText.h2, textAlign: TextAlign.center), diff --git a/lib/ui/widgets/status_banner.dart b/lib/ui/widgets/status_banner.dart index 55f43a0..7936aac 100644 --- a/lib/ui/widgets/status_banner.dart +++ b/lib/ui/widgets/status_banner.dart @@ -57,10 +57,10 @@ class _UpdateCard extends StatelessWidget { Container( padding: const EdgeInsets.all(Sp.x3), decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.6), + color: AppColors.surface.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(R.chip), ), - child: const AppIcon(Ic.cloud, size: 20, color: AppColors.coralDeep), + child: AppIcon(Ic.cloud, size: 20, color: AppColors.coralDeep), ), const SizedBox(width: Sp.x4), Expanded( @@ -238,7 +238,7 @@ class _AlertCard extends StatelessWidget { Container( padding: const EdgeInsets.all(Sp.x3), decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.6), + color: AppColors.surface.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(R.chip), ), child: AppIcon(s.icon, size: 20, color: s.fg), @@ -269,8 +269,8 @@ class _AlertCard extends StatelessWidget { GestureDetector( onTap: onDismiss, behavior: HitTestBehavior.opaque, - child: const Padding( - padding: EdgeInsets.only(left: Sp.x2), + child: Padding( + padding: const EdgeInsets.only(left: Sp.x2), child: AppIcon(Ic.cancel, size: 18, color: AppColors.inkMuted), ), ), diff --git a/lib/ui/workouts/workouts_screen.dart b/lib/ui/workouts/workouts_screen.dart index 9ea6b6a..2eae495 100644 --- a/lib/ui/workouts/workouts_screen.dart +++ b/lib/ui/workouts/workouts_screen.dart @@ -9,6 +9,7 @@ import 'package:provider/provider.dart'; import '../../state/app_state.dart'; import '../activity/live_session_screen.dart'; import '../../theme/theme.dart'; +import '../../theme/theme_switcher.dart'; import '../../theme/tokens.dart'; import '../kit/kit.dart'; import '../kit/charts.dart'; @@ -23,7 +24,7 @@ const _ranges = ['Today', 'Week', 'Month', '3M']; const _rangeKey = ['week', 'week', 'month', 'quarter']; // Today filters week to today // Zone palette (Z1→Z5), shared by the bar + legend. -const _zoneColors = [AppColors.cool, AppColors.loadDetraining, AppColors.good, AppColors.warn, AppColors.coral]; +final _zoneColors = [AppColors.cool, AppColors.loadDetraining, AppColors.good, AppColors.warn, AppColors.coral]; IconData _typeIcon(String? type) { for (final e in _exercises) { if (e.$1 == type) return e.$3; } @@ -93,8 +94,7 @@ Future startWorkoutFlow(BuildContext context) async { // Start the LOCAL live engine (live HR UI + iOS Live Activity + global state) // alongside the backend session, then open the interactive live screen. app.startWorkout(workoutId: id, type: type); - Navigator.of(context).push(MaterialPageRoute( - builder: (_) => LiveSessionScreen(workoutId: id, type: type), + Navigator.of(context).push(themedRoute((_) => LiveSessionScreen(workoutId: id, type: type), )); } catch (_) {/* surfaced as no-op; user can retry */} } @@ -172,7 +172,7 @@ class _WorkoutsScreenState extends State { if (list.isEmpty) ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x6), child: Center( child: Column(children: [ - const AppIcon(Ic.run, size: 32, color: AppColors.inkMuted), + AppIcon(Ic.run, size: 32, color: AppColors.inkMuted), const SizedBox(height: Sp.x3), Text('No workouts', style: AppText.label), const SizedBox(height: Sp.x1), @@ -203,9 +203,9 @@ class _WorkoutsScreenState extends State { padding: const EdgeInsets.only(right: Sp.x6), alignment: Alignment.centerRight, decoration: BoxDecoration(color: AppColors.badSoft, borderRadius: BorderRadius.circular(R.card)), - child: const Row(mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ + child: Row(mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.delete_outline, size: 20, color: AppColors.bad), - SizedBox(width: Sp.x2), + const SizedBox(width: Sp.x2), Text('Delete', style: TextStyle(color: AppColors.bad, fontWeight: FontWeight.w700)), ]), ), @@ -225,7 +225,7 @@ class _WorkoutsScreenState extends State { actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')), TextButton(onPressed: () => Navigator.pop(ctx, true), - child: const Text('Delete', style: TextStyle(color: AppColors.bad, fontWeight: FontWeight.w700))), + child: Text('Delete', style: TextStyle(color: AppColors.bad, fontWeight: FontWeight.w700))), ], ), ); @@ -249,7 +249,7 @@ class _StartButton extends StatelessWidget { child: Container( padding: const EdgeInsets.symmetric(horizontal: Sp.x4, vertical: Sp.x2), decoration: BoxDecoration( - gradient: const LinearGradient( + gradient: LinearGradient( colors: [AppColors.coral, AppColors.coralDeep], begin: Alignment.topLeft, end: Alignment.bottomRight, ), @@ -341,8 +341,7 @@ class _WorkoutTile extends StatelessWidget { return Padding( padding: const EdgeInsets.only(bottom: Sp.x3), child: ProCard( - onTap: () => Navigator.of(context).push(MaterialPageRoute( - builder: (_) => WorkoutDetailScreen(id: w['id'] as String))), + onTap: () => Navigator.of(context).push(themedRoute((_) => WorkoutDetailScreen(id: w['id'] as String))), padding: const EdgeInsets.all(Sp.x4), child: Row(children: [ Container(padding: const EdgeInsets.all(11), @@ -352,8 +351,8 @@ class _WorkoutTile extends StatelessWidget { Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Text(_typeLabel(w['type'] as String?), style: AppText.label), - if (w['source'] == 'auto') ...[const SizedBox(width: Sp.x2), const Tag('AUTO', color: AppColors.inkMuted)], - if (live) ...[const SizedBox(width: Sp.x2), const Tag('LIVE', color: AppColors.coral)], + if (w['source'] == 'auto') ...[const SizedBox(width: Sp.x2), Tag('AUTO', color: AppColors.inkMuted)], + if (live) ...[const SizedBox(width: Sp.x2), Tag('LIVE', color: AppColors.coral)], ]), const SizedBox(height: 2), Text('${_whenLabel(w['start_ts'] as int?)} · ${hm(w['duration_min'] as num?)} · ${w['avg_hr'] ?? '—'} bpm', @@ -366,7 +365,7 @@ class _WorkoutTile extends StatelessWidget { ]), const SizedBox(width: Sp.x2), ], - const AppIcon(Icons.chevron_right, size: 18, color: AppColors.inkMuted), + AppIcon(Icons.chevron_right, size: 18, color: AppColors.inkMuted), ]), ), ); @@ -388,7 +387,7 @@ class WorkoutDetailScreen extends StatelessWidget { actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')), TextButton(onPressed: () => Navigator.pop(ctx, true), - child: const Text('Delete', style: TextStyle(color: AppColors.bad, fontWeight: FontWeight.w700))), + child: Text('Delete', style: TextStyle(color: AppColors.bad, fontWeight: FontWeight.w700))), ], ), ); @@ -406,7 +405,7 @@ class WorkoutDetailScreen extends StatelessWidget { backgroundColor: AppColors.bg, elevation: 0, title: Text('Workout', style: AppText.title), actions: [ IconButton( - icon: const Icon(Icons.delete_outline, color: AppColors.inkMuted), + icon: Icon(Icons.delete_outline, color: AppColors.inkMuted), tooltip: 'Delete workout', onPressed: () => _delete(context), ), @@ -472,8 +471,8 @@ class _WorkoutDetailBodyState extends State<_WorkoutDetailBody> { Text(_typeLabel(d['type'] as String?).toUpperCase(), style: AppText.overline), Text(_whenLabel(d['start_ts'] as int?), style: AppText.captionMuted), ])), - if (d['source'] == 'auto') const Tag('AUTO', color: AppColors.inkMuted), - if (live) const Tag('LIVE', color: AppColors.coral), + if (d['source'] == 'auto') Tag('AUTO', color: AppColors.inkMuted), + if (live) Tag('LIVE', color: AppColors.coral), ]), const SizedBox(height: Sp.x5), Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -513,7 +512,7 @@ class _WorkoutDetailBodyState extends State<_WorkoutDetailBody> { AreaSpark(hr, color: AppColors.coral, height: 110), if (drift != null || ttp != null) ...[ const SizedBox(height: Sp.x4), - const Divider(height: 1, color: AppColors.divider), + Divider(height: 1, color: AppColors.divider), const SizedBox(height: Sp.x2), if (ttp != null) DetailRow(label: 'Time to peak HR', value: '${ttp.toInt()} min'), diff --git a/lib/widget/widget_service.dart b/lib/widget/widget_service.dart index 8562cf3..c5901cc 100644 --- a/lib/widget/widget_service.dart +++ b/lib/widget/widget_service.dart @@ -59,6 +59,18 @@ class WidgetService { } catch (_) {/* widgets unavailable / not configured yet — ignore */} } + /// Tell the iOS widget + Live Activity which appearance the app is rendering + /// (Ember on Paper vs Char) so those native surfaces match — including when the + /// user overrides the OS in-app. Reloads the home widget immediately; the Live + /// Activity picks it up on its next (frequent) content-state update. + static Future setThemeDark(bool dark) async { + try { + await init(); + await HomeWidget.saveWidgetData('theme_dark', dark); + await HomeWidget.updateWidget(iOSName: _iOSName, androidName: _androidName); + } catch (_) {/* widgets unavailable — ignore */} + } + /// Store the backend URL + access JWT so the widget can self-refresh /today /// (~hourly) even when the app is closed. Call alongside push() when signed in. static Future saveAuth(String url, String? jwt) async { diff --git a/pubspec.yaml b/pubspec.yaml index 315e19c..fb1f7c9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: openstrap_edge description: "OpenStrap — open-source WHOOP 4.0 companion. BLE drain → raw-first local store → cloud sync." publish_to: 'none' -version: 0.4.1+6 +version: 0.4.2+7 environment: sdk: ^3.11.4 From 1503ffd5b49fe521f7759257a798bca996b71ebb Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Tue, 16 Jun 2026 01:42:54 +0530 Subject: [PATCH 25/55] ci: also build + attach an unsigned iOS .ipa to releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a macos-latest `ios` job that builds the app with --no-codesign, packages it as an unsigned .ipa, and attaches it to the same tagged release as the APK. For sideloading (AltStore / Sideloadly / TrollStore) — not App Store signed; users re-sign with their own Apple ID. --- .github/workflows/build.yml | 45 +++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d1a244e..3e5d238 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -62,3 +62,48 @@ jobs: with: files: ${{ steps.rename.outputs.apk }} generate_release_notes: true + + # Unsigned iOS .ipa for sideloading (AltStore / Sideloadly / TrollStore). It is + # NOT App Store signed — users re-sign it with their own Apple ID on install. + # Note: free-account sideloads re-sign with a different team id, so App Group + # features (home widget + Live Activity) may not work; there is no iOS OTA + # auto-update either (that path installs an .apk). Attached to the same release. + ios: + runs-on: macos-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: Write .env + run: echo "BACKEND_URL=${{ secrets.BACKEND_URL }}" > .env + + - run: flutter pub get + + - name: Build unsigned iOS app + run: flutter build ios --release --no-codesign --dart-define-from-file=.env + + - name: Package unsigned IPA + id: ipa + run: | + cd build/ios/iphoneos + mkdir -p Payload + cp -R Runner.app Payload/Runner.app + IPA="$GITHUB_WORKSPACE/openstrap-edge-${GITHUB_REF_NAME}-unsigned.ipa" + zip -qr -y "$IPA" Payload + echo "ipa=openstrap-edge-${GITHUB_REF_NAME}-unsigned.ipa" >> "$GITHUB_OUTPUT" + + - uses: actions/upload-artifact@v4 + with: + name: openstrap-edge-ipa + path: ${{ steps.ipa.outputs.ipa }} + + # Attach to the same Release the android job created/updates (upsert by tag). + - name: Attach IPA to release + uses: softprops/action-gh-release@v2 + with: + files: ${{ steps.ipa.outputs.ipa }} From c71a33297db2d97026bb4e167f0f8ab1703808f8 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Tue, 16 Jun 2026 01:45:58 +0530 Subject: [PATCH 26/55] chore: bump to 0.4.3+8 (verify iOS .ipa ships in the release) --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index fb1f7c9..c7f7708 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: openstrap_edge description: "OpenStrap — open-source WHOOP 4.0 companion. BLE drain → raw-first local store → cloud sync." publish_to: 'none' -version: 0.4.2+7 +version: 0.4.3+8 environment: sdk: ^3.11.4 From 632937241c8d459f87bc25fd90b09ab814759d7f Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Tue, 16 Jun 2026 15:05:12 +0530 Subject: [PATCH 27/55] feat: low-battery and charging OS notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds NotificationService — the single OS-notification layer (flutter_local_ notifications) with channels and a notification-id space partitioned so a future FCM/server push system drops in without colliding (one init, one permission prompt, reserved `insights` channel + kServerIdBase id range). DeviceAlerts turns the band's BLE battery/charging state into edge-triggered, de-duped alerts: low battery (<15%, not charging) once per drain with a 25% hysteresis re-arm, and charging-started once per plug-in. Fed from AppState._onEngineState; notification permission is requested after pairing. --- lib/main.dart | 3 + lib/notify/device_alerts.dart | 53 +++++++++++ lib/notify/notification_service.dart | 129 +++++++++++++++++++++++++++ lib/state/app_state.dart | 8 ++ pubspec.lock | 32 +++++++ pubspec.yaml | 4 + 6 files changed, 229 insertions(+) create mode 100644 lib/notify/device_alerts.dart create mode 100644 lib/notify/notification_service.dart diff --git a/lib/main.dart b/lib/main.dart index 8bc01ff..463d33b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import 'app.dart'; import 'ble/ios_ble_restore.dart'; +import 'notify/notification_service.dart'; import 'state/app_state.dart'; import 'theme/theme_controller.dart'; import 'widget/widget_service.dart'; @@ -13,6 +14,8 @@ Future main() async { // relaunch this runs too, so a band-triggered wake reaches runHeadlessSync. await IosBleRestore.init(); await WidgetService.init(); + await NotificationService.instance.init(); // sets up the notif plugin + channel + // Resolve appearance (persisted choice + OS brightness) BEFORE the first frame // so login/signup already paint in the right mode (Ember on Paper / Char). final theme = await ThemeController.bootstrap(); diff --git a/lib/notify/device_alerts.dart b/lib/notify/device_alerts.dart new file mode 100644 index 0000000..175c50d --- /dev/null +++ b/lib/notify/device_alerts.dart @@ -0,0 +1,53 @@ +// device_alerts.dart — turns the band's battery/charging state into OS alerts. +// +// Fed the latest DeviceState on every BLE update (AppState._onEngineState), but +// it is EDGE-TRIGGERED and de-duped, so it fires at most once per real event — +// never on every tick: +// • Low battery (< 15%, not charging): once per drain. Re-arms only after the +// battery recovers past 25% (hysteresis) or goes back on the charger. +// • Charging started: once per plug-in (false/unknown → true). +// +// Presentation goes through NotificationService, the single display layer that a +// future FCM/server-push system also uses — so adding push later doesn't touch +// this file or risk colliding with these alerts. + +import 'notification_service.dart'; + +class DeviceAlerts { + static const double _lowPct = 15; + static const double _rearmPct = 25; // hysteresis so we don't re-fire near 15% + + bool _lowArmed = true; // may we raise a low-battery alert? + bool? _wasCharging; // previous charging state (null = not seen yet) + + final NotificationService _notes; + DeviceAlerts([NotificationService? notes]) + : _notes = notes ?? NotificationService.instance; + + /// Call with the latest device state. Cheap and safe to call on every update. + void onDeviceState({double? batteryPct, bool? charging}) { + // Charging just started → notify once; clear any stale low alert and re-arm + // so the next drain can alert again. + if (charging == true && _wasCharging != true) { + _notes.showDevice( + id: NotificationService.idCharging, + title: 'Charging', + body: 'Your band is on the charger.', + ); + _notes.cancel(NotificationService.idLowBattery); + _lowArmed = true; + } + if (charging != null) _wasCharging = charging; + + if (batteryPct == null) return; + if (batteryPct >= _rearmPct) _lowArmed = true; // recovered → arm for next time + if (charging != true && batteryPct < _lowPct && _lowArmed) { + _notes.showDevice( + id: NotificationService.idLowBattery, + title: 'Low battery', + body: 'Your band is at ${batteryPct.round()}%. Charge it soon.', + ); + _lowArmed = false; + } + } +} diff --git a/lib/notify/notification_service.dart b/lib/notify/notification_service.dart new file mode 100644 index 0000000..a18c0c6 --- /dev/null +++ b/lib/notify/notification_service.dart @@ -0,0 +1,129 @@ +// notification_service.dart — the ONE place OS-level notifications are presented. +// +// Today it serves local, on-device triggers (band battery low / charging, see +// device_alerts.dart). It is deliberately source-agnostic so a future push +// system (Firebase Cloud Messaging / APNs) is plug-and-play and CANNOT collide +// with what we ship now: +// +// • Channels are partitioned by source. Device alerts live on `device_alerts`. +// A future server/push layer gets its own `insights` channel (id reserved +// below) — created by that layer when it lands, so the two never share one. +// • Notification IDs are partitioned. Device alerts use fixed ids < kServerIdBase; +// server/push notifications must start at kServerIdBase so neither overwrites +// the other. +// • One init, one permission prompt. FCM would call show(...) here to display +// foreground messages and reuse ensurePermission() — no second plugin setup. +// +// flutter_local_notifications coexists with firebase_messaging by design: FCM +// delivers, this displays. Nothing here imports or assumes Firebase. + +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +class NotificationService { + NotificationService._(); + static final NotificationService instance = NotificationService._(); + + final FlutterLocalNotificationsPlugin _plugin = + FlutterLocalNotificationsPlugin(); + bool _inited = false; + bool? _granted; + + // ── Channels (one per source — keep them disjoint) ────────────────────────── + static const AndroidNotificationChannel _deviceChannel = + AndroidNotificationChannel( + 'device_alerts', + 'Device alerts', + description: 'Band battery and charging', + importance: Importance.high, + ); + + /// Reserved for a future server/push (FCM) layer. Declared here so it can never + /// be accidentally reused for device alerts; that layer creates it when added. + static const String insightsChannelId = 'insights'; + + // ── Notification id space (never reuse an id across sources) ───────────────── + static const int idLowBattery = 1001; + static const int idCharging = 1002; + + /// Server/push notifications MUST start here so they can't overwrite a device + /// alert (and vice-versa). e.g. `kServerIdBase + serverNotifId.hashCode % 100000`. + static const int kServerIdBase = 2000; + + /// Set up the plugin + the device-alerts channel. Idempotent. Does NOT prompt. + Future init() async { + if (_inited) return; + const AndroidInitializationSettings android = + AndroidInitializationSettings('@mipmap/ic_launcher'); + // We prompt explicitly later (after pairing), not at plugin init. + const DarwinInitializationSettings darwin = DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + requestSoundPermission: false, + ); + await _plugin.initialize( + const InitializationSettings(android: android, iOS: darwin), + ); + await _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.createNotificationChannel(_deviceChannel); + _inited = true; + } + + /// Request notification permission once (iOS always; Android 13+). Safe to call + /// repeatedly — the result is cached. Returns whether notifications are allowed. + Future ensurePermission() async { + await init(); + if (_granted != null) return _granted!; + bool granted = true; + final ios = _plugin.resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin>(); + if (ios != null) { + granted = + await ios.requestPermissions(alert: true, badge: true, sound: true) ?? + false; + } + final android = _plugin.resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>(); + if (android != null) { + granted = await android.requestNotificationsPermission() ?? false; + } + _granted = granted; + return granted; + } + + /// Present (or replace) a device-alert notification. Same id replaces, so we + /// never stack duplicate low-battery alerts. Never throws into the caller. + Future showDevice({ + required int id, + required String title, + required String body, + }) async { + try { + final ok = await ensurePermission(); + if (!ok) return; + await _plugin.show( + id, + title, + body, + NotificationDetails( + android: AndroidNotificationDetails( + _deviceChannel.id, + _deviceChannel.name, + channelDescription: _deviceChannel.description, + importance: Importance.high, + priority: Priority.high, + icon: '@mipmap/ic_launcher', + ), + iOS: const DarwinNotificationDetails(), + ), + ); + } catch (_) {/* notifications are best-effort — never break the app */} + } + + Future cancel(int id) async { + try { + await _plugin.cancel(id); + } catch (_) {} + } +} diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 34498a5..7288642 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -22,6 +22,8 @@ import '../data/db.dart'; import '../data/models.dart'; import '../net/api_client.dart'; import '../live/live_activity.dart'; +import '../notify/device_alerts.dart'; +import '../notify/notification_service.dart'; import '../sync/config.dart'; import '../sync/edge_tracking.dart'; import '../widget/widget_service.dart'; @@ -36,6 +38,7 @@ class AppState extends ChangeNotifier { PairedDevice? paired; DeviceState get device => engine.state; + final DeviceAlerts _deviceAlerts = DeviceAlerts(); Sample? lastSynced; Map dbCounts = {'raw': 0, 'pending': 0}; final List logLines = []; @@ -298,6 +301,8 @@ class AppState extends ChangeNotifier { } void _onEngineState(DeviceState s) { + // Battery-low / charging OS notifications (edge-triggered + de-duped inside). + _deviceAlerts.onDeviceState(batteryPct: s.batteryPct, charging: s.charging); if (_prevConn != 'disconnected' && s.connection == 'disconnected') { if (_keepAlive && isPaired && !_reconnecting) { _log('Connection dropped — reconnecting…'); @@ -318,6 +323,9 @@ class AppState extends ChangeNotifier { Future pairWith(BluetoothDevice d, {String? serial}) async { await PairedDevice.save(d.remoteId.str, serial ?? device.serial); paired = await PairedDevice.load(); + // Now that there's a band to alert about, ask for notification permission + // (a natural moment; battery/charging alerts depend on it). Best-effort. + unawaited(NotificationService.instance.ensurePermission()); final s = serial ?? device.serial; if (config != null && (config!.deviceId.isEmpty || config!.deviceId == 'whoop-unknown') && diff --git a/pubspec.lock b/pubspec.lock index 19014c7..594b636 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -278,6 +278,30 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610 + url: "https://pub.dev" + source: hosted + version: "18.0.1" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52" + url: "https://pub.dev" + source: hosted + version: "8.0.0" flutter_test: dependency: "direct dev" description: flutter @@ -893,6 +917,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.16" + timezone: + dependency: transitive + description: + name: timezone + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 + url: "https://pub.dev" + source: hosted + version: "0.10.1" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c7f7708..d7bf4b1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,10 @@ dependencies: # Home/lock-screen widget bridge (writes a snapshot to the App Group → WidgetKit). home_widget: ^0.6.0 + # OS-level notifications (band battery low / charging). The single display layer; + # a future FCM/server-push integration reuses NotificationService (see lib/notify/). + flutter_local_notifications: ^18.0.1 + # OTA self-update (Android sideload): read our own version, download + install the # signed APK from the backend's update pointer, with a browser fallback. package_info_plus: ^8.1.0 From cfff6084fb76651c65e457738e951cf354e1d6a3 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 17 Jun 2026 00:30:45 +0530 Subject: [PATCH 28/55] Relabel sleep regularity as "Consistency" (not SRI) It's the circular variance of bed/wake times, not the Phillips epoch-agreement Sleep Regularity Index, so stop claiming the SRI name. User-facing label on the sleep detail screen + a stale code comment. Ships with the next app release. --- lib/models/payloads.dart | 3 ++- lib/ui/sleep/sleep_detail_screen.dart | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/models/payloads.dart b/lib/models/payloads.dart index dec8bb9..cf7455e 100644 --- a/lib/models/payloads.dart +++ b/lib/models/payloads.dart @@ -338,7 +338,8 @@ class SleepData { Metric get needMin => metricOf(_row, 'need_min', flags: _flags); Metric get efficiency => Metric.parse(_num(_row['efficiency']), flag: flagFor(_flags, 'duration')); - // Sleep regularity (SRI 0–100); backend column is `regularity` (bare number). + // Sleep-timing consistency 0–100 (circular variance of bed/wake times — not the + // Phillips epoch-agreement SRI); backend column is `regularity` (bare number). Metric get regularity => metricOf(_row, 'regularity', flags: _flags); Metric get lightMin => metricOf(_row, 'light_min', flags: _flags); Metric get deepMin => metricOf(_row, 'deep_min', flags: _flags); diff --git a/lib/ui/sleep/sleep_detail_screen.dart b/lib/ui/sleep/sleep_detail_screen.dart index 005c433..91abb5b 100644 --- a/lib/ui/sleep/sleep_detail_screen.dart +++ b/lib/ui/sleep/sleep_detail_screen.dart @@ -291,9 +291,9 @@ class _SleepDetailScreenState extends State { TrendMetricRow(icon: Ic.pulse, accent: AppColors.coralSoft, label: 'REM sleep', info: infoFor('rem'), value: _hm(_remMin), metric: 'rem', trendTitle: 'REM sleep'), if (_regularity != null) - TrendMetricRow(icon: Ic.calendar, accent: AppColors.good, label: 'Regularity (SRI)', + TrendMetricRow(icon: Ic.calendar, accent: AppColors.good, label: 'Consistency', info: infoFor('regularity'), value: '${_regularity!.round()}', metric: 'regularity', - trendTitle: 'Sleep regularity (SRI)'), + trendTitle: 'Sleep consistency'), ]), ]; } From 5b20a05f619bcaa2413c477163d76258c262f7b1 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 17 Jun 2026 01:26:39 +0530 Subject: [PATCH 29/55] Map band double-tap to a configurable action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a platform-agnostic gesture system: a double-tap (band event 14) can be mapped, from Profile → Gestures, to an action. The mapping is persisted and the picker only offers what the current platform can actually do (native capabilities() + always-available in-app actions). Actions: - Android: media play/pause/next/prev (dispatchMediaKeyEvent — any player), volume up/down, ring phone, flashlight. - iOS: ring phone, flashlight, plus the in-app actions. Media is intentionally NOT offered — iOS has no public API to control a third-party player like Spotify (only Apple Music), so advertising it would mislead. - Both: "mark a moment" (timestamped journal tag) and "start/stop workout" — in-app actions that touch only our own app/backend, so iOS can do them. Safety guards in the dispatcher: recency (a tap drained from flash carries an old ts → never replayed as a live action), debounce, and live-path only (headless drain persists events but does not act on them). Default is "do nothing" — opt-in. New: lib/gestures/*, lib/platform/device_actions.dart, gesture_section UI. Native: openstrap/device_actions channel (MainActivity.kt, ActionBridge in AppDelegate.swift), VIBRATE permission. Ships with the next app release. --- android/app/src/main/AndroidManifest.xml | 2 + .../openstrap/openstrap_edge/MainActivity.kt | 95 ++++++++++++++ ios/Runner/AppDelegate.swift | 55 ++++++++ lib/gestures/device_action.dart | 123 ++++++++++++++++++ lib/gestures/gesture_dispatcher.dart | 76 +++++++++++ lib/gestures/gesture_settings.dart | 58 +++++++++ lib/platform/device_actions.dart | 39 ++++++ lib/protocol/records.dart | 5 + lib/state/app_state.dart | 91 ++++++++++++- lib/ui/profile/gesture_section.dart | 112 ++++++++++++++++ lib/ui/profile/profile_screen.dart | 7 + 11 files changed, 662 insertions(+), 1 deletion(-) create mode 100644 lib/gestures/device_action.dart create mode 100644 lib/gestures/gesture_dispatcher.dart create mode 100644 lib/gestures/gesture_settings.dart create mode 100644 lib/platform/device_actions.dart create mode 100644 lib/ui/profile/gesture_section.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index dd70ae7..eb87ace 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -13,6 +13,8 @@ + + diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/MainActivity.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/MainActivity.kt index 72ae7ff..77a93cd 100644 --- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/MainActivity.kt +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/MainActivity.kt @@ -1,16 +1,27 @@ package wtf.openstrap.openstrap_edge +import android.content.Context import android.content.Intent +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraManager +import android.media.AudioManager +import android.media.RingtoneManager import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import android.view.KeyEvent import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel class MainActivity : FlutterActivity() { private val edgeTrackingChannel = "openstrap/edge_tracking" + private val deviceActionsChannel = "openstrap/device_actions" override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, edgeTrackingChannel) .setMethodCallHandler { call, result -> when (call.method) { @@ -30,5 +41,89 @@ class MainActivity : FlutterActivity() { else -> result.notImplemented() } } + + // Band-gesture actions. All no-risk OS APIs: media-key dispatch (works for any + // player, no permission), system media volume, and a ringtone + vibrate. + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, deviceActionsChannel) + .setMethodCallHandler { call, result -> + when (call.method) { + "capabilities" -> result.success( + listOf( + "media_play_pause", "media_next", "media_prev", + "volume_up", "volume_down", "ring_phone", "torch" + ) + ) + "perform" -> result.success(perform(call.argument("action") ?: "")) + else -> result.notImplemented() + } + } + } + + private fun perform(action: String): Boolean { + return try { + when (action) { + "media_play_pause" -> dispatchMediaKey(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE) + "media_next" -> dispatchMediaKey(KeyEvent.KEYCODE_MEDIA_NEXT) + "media_prev" -> dispatchMediaKey(KeyEvent.KEYCODE_MEDIA_PREVIOUS) + "volume_up" -> adjustVolume(AudioManager.ADJUST_RAISE) + "volume_down" -> adjustVolume(AudioManager.ADJUST_LOWER) + "ring_phone" -> ringPhone() + "torch" -> toggleTorch() + else -> return false + } + true + } catch (e: Exception) { + false + } + } + + private fun audio(): AudioManager = + getSystemService(Context.AUDIO_SERVICE) as AudioManager + + private fun dispatchMediaKey(keyCode: Int) { + val am = audio() + am.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, keyCode)) + am.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_UP, keyCode)) + } + + private fun adjustVolume(direction: Int) { + audio().adjustStreamVolume( + AudioManager.STREAM_MUSIC, direction, AudioManager.FLAG_SHOW_UI + ) + } + + private fun ringPhone() { + val uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE) + RingtoneManager.getRingtone(applicationContext, uri)?.play() + vibrate() + } + + // Torch via CameraManager.setTorchMode — no CAMERA permission required (API 23+). + // We track the on/off state ourselves; if the system turns it off underneath us + // the worst case is one tap that re-syncs the state. + private var torchOn = false + + private fun toggleTorch() { + val cm = getSystemService(Context.CAMERA_SERVICE) as CameraManager + val camId = cm.cameraIdList.firstOrNull { + cm.getCameraCharacteristics(it) + .get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true + } ?: return + torchOn = !torchOn + cm.setTorchMode(camId, torchOn) + } + + @Suppress("DEPRECATION") + private fun vibrate() { + val vibrator: Vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + (getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager).defaultVibrator + } else { + getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createOneShot(500, VibrationEffect.DEFAULT_AMPLITUDE)) + } else { + vibrator.vibrate(500) + } } } diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index a85e997..07e3924 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,5 +1,7 @@ import Flutter import UIKit +import AudioToolbox +import AVFoundation @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { @@ -29,5 +31,58 @@ import UIKit if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "BleRestoreManager") { BleRestoreManager.shared.attach(messenger: registrar.messenger()) } + // Band-gesture actions channel (double-tap → play/pause, skip, ring phone). + if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "ActionBridge") { + ActionBridge.register(messenger: registrar.messenger()) + } + } +} + +// Band-gesture actions on iOS. Media control is deliberately NOT offered: iOS has no +// public API to control a third-party player (Spotify et al.) — only Apple Music via +// systemMusicPlayer — so advertising it would be misleading. The only sanctioned +// no-risk action here today is "ring my phone" (system alert sound + vibrate). System +// volume and call control aren't possible from a sandboxed iOS app and are omitted. +enum ActionBridge { + private static let channelName = "openstrap/device_actions" + + static func register(messenger: FlutterBinaryMessenger) { + let channel = FlutterMethodChannel(name: channelName, binaryMessenger: messenger) + channel.setMethodCallHandler { call, result in + switch call.method { + case "capabilities": + result(["ring_phone", "torch"]) + case "perform": + let args = call.arguments as? [String: Any] ?? [:] + result(perform(args["action"] as? String ?? "")) + default: + result(FlutterMethodNotImplemented) + } + } + } + + private static func perform(_ action: String) -> Bool { + switch action { + case "ring_phone": + AudioServicesPlaySystemSound(SystemSoundID(1005)) // loud alert tone + AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) + return true + case "torch": + // Torch via AVCaptureDevice — toggling it does NOT start a capture session, so + // it needs no camera authorization / NSCameraUsageDescription. (Verifeid on device.) + guard let device = AVCaptureDevice.default(for: .video), device.hasTorch else { + return false + } + do { + try device.lockForConfiguration() + device.torchMode = device.isTorchActive ? .off : .on + device.unlockForConfiguration() + return true + } catch { + return false + } + default: + return false + } } } diff --git a/lib/gestures/device_action.dart b/lib/gestures/device_action.dart new file mode 100644 index 0000000..ce9c202 --- /dev/null +++ b/lib/gestures/device_action.dart @@ -0,0 +1,123 @@ +// device_action.dart — the platform-agnostic catalogue of things a band gesture +// (today: double-tap) can trigger. The enum is the single source of truth shared by +// the settings UI, the persisted mapping, and the native dispatch channel. +// +// Adding a new action is one entry here + one `case` in the native handlers +// (ActionHandler.kt / ActionBridge.swift). Whether a platform actually SUPPORTS an +// action is reported at runtime by DeviceActions.capabilities() — the UI only offers +// what the current OS can do, so e.g. volume control simply doesn't appear on iOS. +// +// FUTURE (deliberately not wired yet — each needs more than a no-risk API or a +// product decision): answer/reject call (Android ANSWER_PHONE_CALLS; impossible on +// iOS), "mark a moment" journal tag, workout lap/stop, torch (camera permission). + +enum DeviceAction { + none, + mediaPlayPause, + mediaNext, + mediaPrev, + volumeUp, + volumeDown, + ringPhone, + torch, + // In-app actions — act on our own app/backend, so they work on every platform + // (iOS can't reach other apps, but it can always do these). + markMoment, + workoutToggle, +} + +extension DeviceActionX on DeviceAction { + /// Stable wire id — used as the SharedPreferences value AND the `action` arg sent + /// over the method channel. Never change these once shipped (persisted). + String get id { + switch (this) { + case DeviceAction.none: + return 'none'; + case DeviceAction.mediaPlayPause: + return 'media_play_pause'; + case DeviceAction.mediaNext: + return 'media_next'; + case DeviceAction.mediaPrev: + return 'media_prev'; + case DeviceAction.volumeUp: + return 'volume_up'; + case DeviceAction.volumeDown: + return 'volume_down'; + case DeviceAction.ringPhone: + return 'ring_phone'; + case DeviceAction.torch: + return 'torch'; + case DeviceAction.markMoment: + return 'mark_moment'; + case DeviceAction.workoutToggle: + return 'workout_toggle'; + } + } + + /// Short label for the settings picker. + String get label { + switch (this) { + case DeviceAction.none: + return 'Do nothing'; + case DeviceAction.mediaPlayPause: + return 'Play / pause music'; + case DeviceAction.mediaNext: + return 'Next track'; + case DeviceAction.mediaPrev: + return 'Previous track'; + case DeviceAction.volumeUp: + return 'Volume up'; + case DeviceAction.volumeDown: + return 'Volume down'; + case DeviceAction.ringPhone: + return 'Ring my phone'; + case DeviceAction.torch: + return 'Flashlight'; + case DeviceAction.markMoment: + return 'Mark a moment'; + case DeviceAction.workoutToggle: + return 'Start / stop workout'; + } + } + + /// One-line description shown under the label. + String get blurb { + switch (this) { + case DeviceAction.none: + return 'Double-tap does nothing.'; + case DeviceAction.mediaPlayPause: + return 'Toggle whatever is playing.'; + case DeviceAction.mediaNext: + return 'Skip to the next track.'; + case DeviceAction.mediaPrev: + return 'Go back a track.'; + case DeviceAction.volumeUp: + return 'Raise media volume a step.'; + case DeviceAction.volumeDown: + return 'Lower media volume a step.'; + case DeviceAction.ringPhone: + return 'Play a loud sound so you can find your phone.'; + case DeviceAction.torch: + return "Toggle your phone's flashlight."; + case DeviceAction.markMoment: + return 'Tag the current moment in your journal.'; + case DeviceAction.workoutToggle: + return 'Begin or end a workout from your wrist.'; + } + } + + /// In-app actions act on our own app/backend (handled in Dart, no native call, + /// available on every platform). Everything else (except `none`) is native. + bool get isInApp => + this == DeviceAction.markMoment || this == DeviceAction.workoutToggle; + + bool get isNative => this != DeviceAction.none && !isInApp; + + static DeviceAction? fromId(String? id) { + if (id == null) return null; + for (final a in DeviceAction.values) { + if (a.id == id) return a; + } + return null; + } +} diff --git a/lib/gestures/gesture_dispatcher.dart b/lib/gestures/gesture_dispatcher.dart new file mode 100644 index 0000000..f912572 --- /dev/null +++ b/lib/gestures/gesture_dispatcher.dart @@ -0,0 +1,76 @@ +// gesture_dispatcher.dart — turns a live band event into an action, with the guards +// that keep it safe. Wired ONLY into the foreground/live event path (AppState), never +// the headless drain (background_sync persists events but must not replay them). +// +// Two guards do the real work: +// • Recency — a double-tap drained from the band's flash carries an OLD timestamp; +// we refuse to act on anything that didn't happen in the last few seconds, so a +// sync catch-up can't fire play/pause for a tap from this morning. (Only applies +// when the band ts is plausible; a bogus RTC falls through to the debounce alone.) +// • Debounce — the band can emit the event more than once per physical tap. + +import 'device_action.dart'; +import 'gesture_settings.dart'; +import '../platform/device_actions.dart'; + +class GestureDispatcher { + final GestureSettings settings; + final void Function(String line)? log; + + /// In-app action handlers (supplied by AppState). Native actions go to the + /// platform channel instead. + final Future Function()? onMarkMoment; + final Future Function()? onWorkoutToggle; + + GestureDispatcher({ + required this.settings, + this.log, + this.onMarkMoment, + this.onWorkoutToggle, + }); + + static const int _doubleTapEventId = 14; // EventId.doubleTap + static const int _recencyWindowSec = 6; // older than this = a drained/historical tap + static const int _plausibleAgeCapSec = 86400; // ignore the recency check if ts looks bogus + static const int _debounceMs = 2000; + + int _lastFiredMs = 0; + + /// Feed every live event here (id, band timestamp seconds, raw hex). Cheap to call + /// for non-gesture events — it returns immediately. + void onEvent(int eventId, int tsEpoch, String hex) { + if (eventId != _doubleTapEventId) return; + final action = settings.doubleTap; + if (action == DeviceAction.none) return; + + final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final age = nowSec - tsEpoch; + // Stale only when the ts is plausibly a real (recent-ish but past) historical + // record. A wildly-off ts from an unset RTC is treated as "can't tell" → allow, + // and rely on debounce + the live-only wiring. + if (tsEpoch > 0 && age > _recencyWindowSec && age < _plausibleAgeCapSec) { + log?.call('[gesture] ignoring stale double-tap (${age}s old)'); + return; + } + + final nowMs = DateTime.now().millisecondsSinceEpoch; + if (nowMs - _lastFiredMs < _debounceMs) return; + _lastFiredMs = nowMs; + + log?.call('[gesture] double-tap → ${action.id}'); + if (action.isInApp) { + switch (action) { + case DeviceAction.markMoment: + onMarkMoment?.call(); + break; + case DeviceAction.workoutToggle: + onWorkoutToggle?.call(); + break; + default: + break; + } + return; + } + DeviceActions.perform(action.id); + } +} diff --git a/lib/gestures/gesture_settings.dart b/lib/gestures/gesture_settings.dart new file mode 100644 index 0000000..f7a500e --- /dev/null +++ b/lib/gestures/gesture_settings.dart @@ -0,0 +1,58 @@ +// gesture_settings.dart — the persisted band-gesture → action mapping, plus the +// per-platform set of supported actions. Same persistence pattern as ThemeController +// (SharedPreferences) and same ChangeNotifier shape so the settings UI rebuilds and +// the dispatcher reads a live value. + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../platform/device_actions.dart'; +import 'device_action.dart'; + +class GestureSettings extends ChangeNotifier { + static const _kDoubleTap = 'gesture_double_tap'; + + /// What a double-tap currently does. Defaults to nothing — opt-in, so we never + /// surprise a user (or pay the iOS bg keep-alive cost) until they pick an action. + DeviceAction doubleTap = DeviceAction.none; + + /// Actions offerable on THIS platform: `none` always, plus whatever native says + /// it can do. Until bootstrap() runs we only know `none`. + Set supported = {DeviceAction.none}; + + /// Load the saved mapping and query native capabilities. Call once at startup. + Future bootstrap() async { + final prefs = await SharedPreferences.getInstance(); + doubleTap = DeviceActionX.fromId(prefs.getString(_kDoubleTap)) ?? DeviceAction.none; + + final caps = await DeviceActions.capabilities(); + supported = { + DeviceAction.none, + // In-app actions act on our own app, so they're offerable everywhere. + ...DeviceAction.values.where((a) => a.isInApp), + // Native actions: only what this platform reported it can do. + ...caps.map(DeviceActionX.fromId).whereType(), + }; + + // If a previously-chosen action isn't supported here (e.g. settings synced from + // an Android backup onto an iPhone), fall back to none rather than silently + // mapping to something unsupported. + if (!supported.contains(doubleTap)) { + doubleTap = DeviceAction.none; + await prefs.setString(_kDoubleTap, doubleTap.id); + } + notifyListeners(); + } + + Future setDoubleTap(DeviceAction action) async { + if (action == doubleTap) return; + doubleTap = action; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kDoubleTap, action.id); + notifyListeners(); + } + + /// True once the user has mapped a real-time action — callers can use this to + /// decide whether the iOS background BLE keep-alive is worth enabling. + bool get hasActiveMapping => doubleTap != DeviceAction.none; +} diff --git a/lib/platform/device_actions.dart b/lib/platform/device_actions.dart new file mode 100644 index 0000000..5ffa578 --- /dev/null +++ b/lib/platform/device_actions.dart @@ -0,0 +1,39 @@ +// device_actions.dart — the Dart side of the `openstrap/device_actions` method +// channel. Mirrors the edge_tracking / live_activity bridges: a thin wrapper that +// asks native what it can do (capabilities) and tells it to do one thing (perform). +// +// Native handlers: android/.../ActionHandler.kt (via MainActivity), ios ActionBridge. +// All actions use no-risk OS APIs (media-key dispatch, system volume, a ringtone + +// vibrate) — no special runtime permissions beyond VIBRATE (a normal permission). + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +class DeviceActions { + static const _ch = MethodChannel('openstrap/device_actions'); + + /// The set of action ids this OS+device can actually perform. The settings UI + /// uses this to hide unsupported actions (e.g. system volume on iOS). Returns an + /// empty set if native isn't reachable — the UI then offers only `none`. + static Future> capabilities() async { + try { + final r = await _ch.invokeMethod>('capabilities'); + return (r ?? const []).map((e) => e.toString()).toSet(); + } catch (e) { + debugPrint('[device_actions] capabilities failed: $e'); + return {}; + } + } + + /// Execute one action by its wire id. Returns true on success. Never throws — + /// a gesture failing is never worth crashing a background isolate over. + static Future perform(String actionId) async { + try { + final ok = await _ch.invokeMethod('perform', {'action': actionId}); + return ok ?? false; + } catch (e) { + debugPrint('[device_actions] perform($actionId) failed: $e'); + return false; + } + } +} diff --git a/lib/protocol/records.dart b/lib/protocol/records.dart index e307fec..e78c906 100644 --- a/lib/protocol/records.dart +++ b/lib/protocol/records.dart @@ -231,6 +231,11 @@ EventInfo? parseEvent(Uint8List inner) { case EventId.batteryPackRemoved: dec['pack_connected'] = eid == EventId.batteryPackConnected; break; + case EventId.doubleTap: + // Surfaced so the live event path can map it to a user action (see + // gestures/gesture_dispatcher.dart). Payload beyond the id is unused today. + dec['double_tap'] = true; + break; } return EventInfo(eid, name, ts, dec); } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 7288642..eec7e0f 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -11,6 +11,7 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart' show HapticFeedback; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -19,6 +20,8 @@ import '../models/app_status.dart'; import '../ble/ble_engine.dart'; import '../ble/ios_ble_restore.dart'; import '../data/db.dart'; +import '../gestures/gesture_settings.dart'; +import '../gestures/gesture_dispatcher.dart'; import '../data/models.dart'; import '../net/api_client.dart'; import '../live/live_activity.dart'; @@ -39,6 +42,10 @@ class AppState extends ChangeNotifier { DeviceState get device => engine.state; final DeviceAlerts _deviceAlerts = DeviceAlerts(); + + /// Band-gesture → action mapping (double-tap, etc.). Exposed for the settings UI. + final GestureSettings gestureSettings = GestureSettings(); + late final GestureDispatcher _gestureDispatcher; Sample? lastSynced; Map dbCounts = {'raw': 0, 'pending': 0}; final List logLines = []; @@ -131,16 +138,30 @@ class AppState extends ChangeNotifier { } AppState() { + _gestureDispatcher = GestureDispatcher( + settings: gestureSettings, + log: _log, + onMarkMoment: _markMomentFromGesture, + onWorkoutToggle: _toggleWorkoutFromGesture, + ); engine = BleEngine( onRecord: _onRecord, onState: _onEngineState, log: _log, - onEvent: (id, ts, hex) => LocalDb.insertEvent(id, ts, hex), + onEvent: _onLiveEvent, onRecordsBatch: LocalDb.insertRecordsBatch, ); _init(); } + // Live (foreground / kept-alive) event path: persist every event, then let the + // gesture dispatcher act on it. Headless drain (background_sync) persists only — + // it must never replay an old tap as a live action. + void _onLiveEvent(int id, int ts, String hex) { + LocalDb.insertEvent(id, ts, hex); + _gestureDispatcher.onEvent(id, ts, hex); + } + Future _init() async { config = await BackendConfig.load(); session = await Session.load(); @@ -149,6 +170,9 @@ class AppState extends ChangeNotifier { lastSynced = await LocalDb.latestSample(); dbCounts = await LocalDb.counts(); _savedAlarm = (await SharedPreferences.getInstance()).getInt('alarm_epoch'); + // Band-gesture mapping: load the saved action + query native capabilities so the + // settings UI knows what this platform supports. Best-effort, non-blocking. + unawaited(gestureSettings.bootstrap()); initialized = true; notifyListeners(); // App status (OTA pointer + admin alert banner) — best-effort, non-blocking. @@ -604,6 +628,71 @@ class AppState extends ChangeNotifier { LiveActivity.end(); } + // ── band-gesture actions (in-app) ───────────────────────────────────────────── + // Driven by the double-tap dispatcher (lib/gestures). Native actions (media, torch, + // ring) go over the platform channel; these two act on our own app/backend, so they + // work on every platform — and a haptic confirms the tap registered. + + /// Double-tap → start a workout if none is live, else end the active one. Mirrors + /// the UI start/stop flow (api session + local live engine), minus the type picker + /// (defaults to 'other'; the user can refine it in the app). + Future _toggleWorkoutFromGesture() async { + try { + if (activeWorkout != null) { + final id = activeWorkout!.workoutId; + stopWorkout(); + if (id != null) { + try { + await api?.endWorkout(id); + } catch (_) {/* local already stopped; backend trues up on next sync */} + } + } else { + if (api == null) return; + String? id; + try { + final w = await api!.startWorkout('other'); + id = w['workout_id'] as String?; + } catch (_) {/* still start locally so the user gets the live session */} + startWorkout(workoutId: id, type: 'other'); + } + await HapticFeedback.mediumImpact(); + } catch (e) { + _log('[gesture] workout toggle failed: $e'); + } + } + + /// Double-tap → stamp a timestamped tag onto today's journal (read-modify-write so + /// existing tags/note survive). "Remember this" for a spike, a set, a feeling. + Future _markMomentFromGesture() async { + final a = api; + if (a == null) return; + try { + final now = DateTime.now(); + final date = '${now.year.toString().padLeft(4, '0')}-' + '${now.month.toString().padLeft(2, '0')}-' + '${now.day.toString().padLeft(2, '0')}'; + final hhmm = '${now.hour.toString().padLeft(2, '0')}:' + '${now.minute.toString().padLeft(2, '0')}'; + List tags = []; + String note = ''; + try { + final journal = await a.getJournal(range: '7d'); + final today = journal.firstWhere( + (r) => r['date'] == date, + orElse: () => {}, + ); + tags = (today['tags'] as List?)?.map((e) => e.toString()).toList() ?? []; + note = (today['note'] as String?) ?? ''; + } catch (_) {/* fresh day / offline — start clean */} + tags.add('moment $hhmm'); + await a.postJournal(date, tags, note); + _log('[gesture] moment marked at $hhmm'); + await HapticFeedback.mediumImpact(); + } catch (e) { + _log('[gesture] mark moment failed: $e'); + } + } + void _tickWorkout() { final w = activeWorkout; if (w == null) return; diff --git a/lib/ui/profile/gesture_section.dart b/lib/ui/profile/gesture_section.dart new file mode 100644 index 0000000..d98b581 --- /dev/null +++ b/lib/ui/profile/gesture_section.dart @@ -0,0 +1,112 @@ +// Gesture settings — maps a band double-tap to an action. Only offers actions the +// current platform actually supports (from native capabilities); falls back to a +// "nothing available yet" note otherwise. Same card/sheet idiom as the rest of Profile. + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../gestures/device_action.dart'; +import '../../gestures/gesture_settings.dart'; +import '../../state/app_state.dart'; +import '../../theme/theme.dart'; +import '../../theme/tokens.dart'; +import '../kit/kit.dart'; + +class GestureSettingsCard extends StatelessWidget { + const GestureSettingsCard({super.key}); + + @override + Widget build(BuildContext context) { + final settings = context.read().gestureSettings; + return AnimatedBuilder( + animation: settings, + builder: (context, _) { + return ProCard( + onTap: () => _pick(context, settings), + child: Row( + children: [ + Icon(Ic.watch, size: 22, color: AppColors.coral), + const SizedBox(width: Sp.x4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Double-tap your band', style: AppText.title), + const SizedBox(height: 2), + Text(settings.doubleTap.label, style: AppText.bodySoft), + ], + ), + ), + Icon(Ic.arrowRight, size: 18, color: AppColors.inkMuted), + ], + ), + ); + }, + ); + } + + void _pick(BuildContext context, GestureSettings settings) { + final options = + DeviceAction.values.where((a) => settings.supported.contains(a)).toList(); + showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetCtx) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(Sp.x5, Sp.x5, Sp.x5, Sp.x4), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Double-tap action', style: AppText.h2), + const SizedBox(height: 4), + Text('What happens when you double-tap the band.', + style: AppText.captionMuted), + const SizedBox(height: Sp.x4), + ...options.map((a) { + final selected = a == settings.doubleTap; + return InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () { + settings.setDoubleTap(a); + Navigator.of(sheetCtx).pop(); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: Sp.x3, horizontal: Sp.x2), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(a.label, style: AppText.body), + const SizedBox(height: 2), + Text(a.blurb, style: AppText.caption), + ], + ), + ), + if (selected) + Icon(Ic.check, size: 20, color: AppColors.good), + ], + ), + ), + ); + }), + if (options.length <= 1) ...[ + const SizedBox(height: Sp.x3), + Text('No band actions are available on this device yet.', + style: AppText.caption), + ], + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/ui/profile/profile_screen.dart b/lib/ui/profile/profile_screen.dart index 54fc867..f47ca8b 100644 --- a/lib/ui/profile/profile_screen.dart +++ b/lib/ui/profile/profile_screen.dart @@ -10,6 +10,7 @@ import '../../theme/theme_switcher.dart'; import '../../theme/tokens.dart'; import '../kit/kit.dart'; import '../today/step_goal_screen.dart'; +import 'gesture_section.dart'; class ProfileScreen extends StatelessWidget { const ProfileScreen({super.key}); @@ -131,6 +132,12 @@ class ProfileScreen extends StatelessWidget { const SizedBox(height: Sp.x7), + // ── Gestures ───────────────────────────────────────────────── + const SectionHeader('Gestures'), + const GestureSettingsCard(), + + const SizedBox(height: Sp.x7), + // ── Backend ────────────────────────────────────────────────── const SectionHeader('Backend'), ProCard( From c93e45fff4b8f3b9d705edd89ff91211968ccb4a Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 17 Jun 2026 11:06:54 +0530 Subject: [PATCH 30/55] Connection resilience + honest "no data" workouts (v0.4.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-caused from a real report: a user ran with their phone left behind; the band's data for the run never made it to the cloud and the workout showed 0.0 strain · 0 bpm. Two problems: Connection resilience (the actual fix): - Reconnect was capped at 5 tries (~30s) then gave up — a phone-free run leaves the band out of range far longer, so its offline backlog was never pulled until the next manual app-open. Reconnect now retries for as long as a session is active (capped-exponential backoff). - The reconnect drain was capped at 30s, cutting a large offline backlog short. Now a full drain runs on reconnect so the entire buffered window is recovered. - Background (iOS restore-wake) drain was capped at 120s — also removed; a truncated wake persists what it got and resumes from the cursor next time. - On workout end, force a re-drain over the live connection so a session that rode the live feed still gets its window backfilled from flash. Honest UX: - Workouts list + detail now show "No data / no HR" (not a misleading "0.0 strain · 0 bpm") when a session has zero worn-HR coverage, with a line explaining the band wasn't syncing for that window. --- lib/state/app_state.dart | 39 +++++++++++++++++++++++++--- lib/sync/background_sync.dart | 6 ++++- lib/ui/workouts/workouts_screen.dart | 32 ++++++++++++++++------- pubspec.yaml | 2 +- 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index eec7e0f..9510347 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -469,8 +469,15 @@ class AppState extends ChangeNotifier { if (_reconnecting || paired == null) return; _reconnecting = true; try { - for (int attempt = 1; attempt <= 5 && _keepAlive; attempt++) { - await Future.delayed(Duration(seconds: 2 * attempt)); + // Keep trying for as long as we still want the link (a session is active) — + // a runner who left their phone behind can be out of range for an hour. The + // old 5-try (~30s) limit gave up long before they got back, so the band's + // offline backlog was never pulled until the next manual app open. Capped + // exponential backoff so we don't hammer the radio. + int attempt = 0; + while (_keepAlive && !engine.isConnected) { + attempt++; + await Future.delayed(Duration(seconds: (2 * attempt).clamp(2, 30))); if (!_keepAlive) break; if (await engine.connectToRemoteId(paired!.remoteId)) { // Reclaim the band from the iOS restore central so it stops competing. @@ -480,10 +487,14 @@ class AppState extends ChangeNotifier { } _startFlusher(); // ensure live uploads run even on a reconnect-only path EdgeTracking.start(); // ensure the Android foreground service is up too - await engine.runSync(timeout: const Duration(seconds: 30)); + // FULL drain (no short timeout): pull the ENTIRE offline backlog the band + // buffered to flash while we were out of range. A 30s cap silently cut a + // long gap (e.g. a phone-free run) short and lost the rest. + await engine.runSync(); await upload(); await engine.enableLiveStreams(); - _log('Reconnected.'); + dbCounts = await LocalDb.counts(); + _log('Reconnected — backlog drained.'); break; } } @@ -494,6 +505,22 @@ class AppState extends ChangeNotifier { } } + /// Pull anything the band flashed that we don't have yet, over the CURRENT + /// connection (no reconnect, no teardown). Used when a workout ends so a session + /// that rode the live feed still gets its window backfilled from flash. + Future forceResync() async { + if (!engine.isConnected) return; + try { + await engine.runSync(); + await engine.enableLiveStreams(); + await upload(); + dbCounts = await LocalDb.counts(); + notifyListeners(); + } catch (e) { + _log('Resync failed: $e'); + } + } + Future syncNow() => openSession(); Future endSession() async { @@ -626,6 +653,10 @@ class AppState extends ChangeNotifier { notifyListeners(); _log('Live session ended. Burned $finalKcal kcal.'); LiveActivity.end(); + // A workout often rides the live feed; if the connection blipped during it, the + // band may hold that window in flash. Pull it now over the live connection so the + // just-finished session isn't left with a gap. + unawaited(forceResync()); } // ── band-gesture actions (in-app) ───────────────────────────────────────────── diff --git a/lib/sync/background_sync.dart b/lib/sync/background_sync.dart index 8f5eaa1..8f414d0 100644 --- a/lib/sync/background_sync.dart +++ b/lib/sync/background_sync.dart @@ -54,7 +54,11 @@ Future runHeadlessSync() async { return true; } try { - await engine.runSync(timeout: const Duration(seconds: 120)); + // Full drain (default timeout): a phone-free run/sleep can leave a large + // offline backlog on the band's flash. If iOS cuts the background window + // short, the drain persists what it got (flush-before-ACK) and the next wake + // resumes from the cursor — so a longer budget only helps, never hurts. + await engine.runSync(); await uploader.uploadPending(); await uploader.uploadEvents(); } finally { diff --git a/lib/ui/workouts/workouts_screen.dart b/lib/ui/workouts/workouts_screen.dart index 2eae495..7ec6a77 100644 --- a/lib/ui/workouts/workouts_screen.dart +++ b/lib/ui/workouts/workouts_screen.dart @@ -338,6 +338,10 @@ class _WorkoutTile extends StatelessWidget { Widget build(BuildContext context) { final live = w['status'] == 'live'; final strain = (w['strain'] as num?); + // No worn HR minutes in the window → avg_hr comes back 0 and strain 0. That's + // not a zero-effort workout, it's missing data (band wasn't syncing). Show it + // as such instead of a misleading "0.0 strain · 0 bpm". + final noData = !live && (((w['avg_hr'] as num?) ?? 0) == 0); return Padding( padding: const EdgeInsets.only(bottom: Sp.x3), child: ProCard( @@ -355,14 +359,16 @@ class _WorkoutTile extends StatelessWidget { if (live) ...[const SizedBox(width: Sp.x2), Tag('LIVE', color: AppColors.coral)], ]), const SizedBox(height: 2), - Text('${_whenLabel(w['start_ts'] as int?)} · ${hm(w['duration_min'] as num?)} · ${w['avg_hr'] ?? '—'} bpm', + Text('${_whenLabel(w['start_ts'] as int?)} · ${hm(w['duration_min'] as num?)} · ${noData ? 'no HR' : '${w['avg_hr'] ?? '—'} bpm'}', style: AppText.captionMuted), ])), if (!live) ...[ - Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ - Text(strain == null ? '—' : strain.toStringAsFixed(1), style: AppText.metricSm.copyWith(fontSize: 18)), - Text('strain', style: AppText.captionMuted), - ]), + noData + ? Text('No data', style: AppText.captionMuted) + : Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ + Text(strain == null ? '—' : strain.toStringAsFixed(1), style: AppText.metricSm.copyWith(fontSize: 18)), + Text('strain', style: AppText.captionMuted), + ]), const SizedBox(width: Sp.x2), ], AppIcon(Icons.chevron_right, size: 18, color: AppColors.inkMuted), @@ -448,6 +454,9 @@ class _WorkoutDetailBodyState extends State<_WorkoutDetailBody> { final curve = (d['recovery_curve'] as List?)?.whereType().toList() ?? const []; final live = d['status'] == 'live'; final strain = _n(d['strain']); + // Window had no worn HR minutes → avg_hr 0, strain 0. Missing data, not zero + // effort: show "no HR recorded" rather than a misleading 0.0 ring. + final noData = !live && (((d['avg_hr'] as num?) ?? 0) == 0); final drift = _n(d['hr_drift_pct']); final ttp = _n(d['time_to_peak_min']); // Minute-level HR curve only for recent workouts; the summary (avg/max/zones/ @@ -481,7 +490,7 @@ class _WorkoutDetailBodyState extends State<_WorkoutDetailBody> { const SizedBox(height: Sp.x1), Text('duration', style: AppText.bodySoft), ])), - if (strain != null) + if (strain != null && !noData) RingStat( t: (strain / 21).clamp(0.0, 1.0), color: AppColors.coral, size: 92, stroke: 11, center: Column(mainAxisSize: MainAxisSize.min, children: [ @@ -490,11 +499,16 @@ class _WorkoutDetailBodyState extends State<_WorkoutDetailBody> { ]), ), ]), + if (noData) ...[ + const SizedBox(height: Sp.x4), + Text("No heart-rate data was recorded during this workout — the band wasn't syncing for this window, so strain and zones can't be computed.", + style: AppText.captionMuted), + ], const SizedBox(height: Sp.x5), Row(children: [ - _heroStat('${d['avg_hr'] ?? '—'}', 'avg bpm'), - _heroStat('${d['max_hr'] ?? '—'}', 'max bpm'), - _heroStat('${d['min_hr'] ?? '—'}', 'min bpm'), + _heroStat(noData ? '—' : '${d['avg_hr'] ?? '—'}', 'avg bpm'), + _heroStat(noData ? '—' : '${d['max_hr'] ?? '—'}', 'max bpm'), + _heroStat(noData ? '—' : '${d['min_hr'] ?? '—'}', 'min bpm'), _heroStat('${d['calories'] ?? 0}', 'kcal'), ]), ]), diff --git a/pubspec.yaml b/pubspec.yaml index d7bf4b1..ffbafb4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: openstrap_edge description: "OpenStrap — open-source WHOOP 4.0 companion. BLE drain → raw-first local store → cloud sync." publish_to: 'none' -version: 0.4.3+8 +version: 0.4.4+9 environment: sdk: ^3.11.4 From 5e7c26163b7d908e01d98e5c29c10566bbafd5a4 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 17 Jun 2026 13:43:46 +0530 Subject: [PATCH 31/55] Fix dark-mode rebuild storm that starved the background BLE connection (v0.4.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dark mode (v0.4.2) turned the home stack's screens from const to fresh instances, so every AppState notifyListeners() — which fires ~1 Hz during a live connection (each HR sample, each log line) — repainted the entire 5-tab tree + the ThemeSwitchOverlay. Backgrounded overnight, that sustained CPU got the app throttled/killed, the BLE link dropped, and the night's data was lost (band doesn't retain offline). Evidence: per-day coverage was a flat 100% through the last pre-dark-mode night, then the first full night on the dark-mode build broke with a multi-hour gap; daytime (frequent foreground reconnects) stayed fine. Fix: _Gate now context.select's a single AppRoute value instead of watching all of AppState, so it rebuilds only on a real route transition or a theme flip — never on the per-second ticks. _Shell doesn't watch AppState, so the kept-alive tabs are built once and sit quiet in the background. Theme flips still recolor the whole stack (via the ThemeController watch), so dark mode is fully preserved. The only remaining 1 Hz rebuild is the tiny live-workout banner, and only during a workout. --- lib/app.dart | 35 +++++++++++++++++++++++------------ lib/state/app_state.dart | 16 ++++++++++++++++ pubspec.yaml | 2 +- 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 5737f17..0255b17 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -76,22 +76,33 @@ class _Gate extends StatelessWidget { const _Gate(); @override Widget build(BuildContext context) { - final app = context.watch(); + // SELECT, not watch: rebuild only when the ROUTE actually changes (rare) — not + // on every ~1 Hz AppState tick (live HR, log lines). Watching the whole AppState + // here used to repaint the entire home stack every second, which starved the + // background BLE connection on long idle stretches (lost overnight data). + final route = context.select((a) => a.route); // Depend on the theme too → the whole home stack (onboarding screens, the // shell + its tabs) rebuilds with fresh colours the instant the mode flips. context.watch(); - if (!app.initialized) { - return Scaffold( - body: Center(child: CircularProgressIndicator(color: AppColors.coral)), - ); + // Not const: these must be fresh instances so a theme flip re-runs their build + // (State is preserved — same type at the same position). Cheap now: only built + // on a route change or a theme flip, never on the per-second AppState ticks. + switch (route) { + case AppRoute.loading: + return Scaffold( + body: Center(child: CircularProgressIndicator(color: AppColors.coral)), + ); + case AppRoute.backend: + return BackendChoiceScreen(); + case AppRoute.auth: + return AuthScreen(); + case AppRoute.profile: + return ProfileSetupScreen(); + case AppRoute.pairing: + return PairingScreen(); + case AppRoute.shell: + return _Shell(); } - // Not const: these must be fresh instances so a theme flip re-runs their - // build (State is preserved — same type at the same position). - if (!app.backendChosen) return BackendChoiceScreen(); - if (!app.isAuthenticated) return AuthScreen(); - if (!app.profileComplete) return ProfileSetupScreen(); - if (!app.isPaired) return PairingScreen(); - return _Shell(); } } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 9510347..f1c1bac 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -33,6 +33,9 @@ import '../widget/widget_service.dart'; import '../sync/file_log.dart'; import '../sync/uploader.dart'; +/// The onboarding/app gate states, in order. See [AppState.route]. +enum AppRoute { loading, backend, auth, profile, pairing, shell } + class AppState extends ChangeNotifier { late final BleEngine engine; BackendConfig? config; @@ -86,6 +89,19 @@ class AppState extends ChangeNotifier { u['weight_kg'] != null; } + /// The single onboarding/route the UI gate is in. `_Gate` selects on THIS so it + /// rebuilds only on a real route transition — NOT on every ~1 Hz notifyListeners + /// (live HR, log lines), which used to repaint the whole home stack each second + /// and starve the background BLE connection. + AppRoute get route { + if (!initialized) return AppRoute.loading; + if (!backendChosen) return AppRoute.backend; + if (!isAuthenticated) return AppRoute.auth; + if (!profileComplete) return AppRoute.profile; + if (!isPaired) return AppRoute.pairing; + return AppRoute.shell; + } + // ── app status: OTA update pointer + admin-pushed alert banner ────────────── AppStatus? appStatus; int _currentBuild = 0; // our build number (from package_info); 0 if unknown diff --git a/pubspec.yaml b/pubspec.yaml index ffbafb4..c8e698e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: openstrap_edge description: "OpenStrap — open-source WHOOP 4.0 companion. BLE drain → raw-first local store → cloud sync." publish_to: 'none' -version: 0.4.4+9 +version: 0.4.5+10 environment: sdk: ^3.11.4 From 4f60448ac86b79e07dd32c319da1981ed5607c8d Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 18 Jun 2026 23:55:17 +0530 Subject: [PATCH 32/55] Fix release-build blank screen on launch (v0.4.6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NotificationService.init() initialized flutter_local_notifications with @mipmap/ic_launcher, but the launcher icon was renamed to launcher_icon (see AndroidManifest). On API 26+ that resource fails to resolve, so initialize() threw PlatformException(invalid_icon). Because it is awaited in main() before runApp(), the throw aborted startup and left the app stuck on the native launch screen (blank/black/icon) — release only; debug masked it via resource resolution. - notification_service: use @mipmap/launcher_icon (the resource that exists). - main(): wrap optional startup inits (IosBleRestore, WidgetService, NotificationService) in a guarded _safeInit, and fall back to a system-brightness ThemeController if bootstrap fails, so runApp() is always reached and no startup-service failure can blank the app again. --- lib/main.dart | 37 +++++++++++++++++++++++----- lib/notify/notification_service.dart | 6 ++++- pubspec.yaml | 2 +- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 463d33b..cb4c472 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,15 +10,30 @@ import 'widget/widget_service.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - // iOS: register the CoreBluetooth-restoration wake handler. On a background - // relaunch this runs too, so a band-triggered wake reaches runHeadlessSync. - await IosBleRestore.init(); - await WidgetService.init(); - await NotificationService.instance.init(); // sets up the notif plugin + channel + + // Optional startup services. A failure in any one of these must NEVER block the + // first frame — they are awaited before runApp, so an unguarded throw (e.g. the + // flutter_local_notifications `invalid_icon` crash) leaves the app stuck on the + // native launch screen (blank/icon). Guard each so the UI always boots. + // iOS: registers the CoreBluetooth-restoration wake handler (no-op on Android). + await _safeInit('IosBleRestore', IosBleRestore.init); + await _safeInit('WidgetService', WidgetService.init); + await _safeInit('NotificationService', NotificationService.instance.init); // Resolve appearance (persisted choice + OS brightness) BEFORE the first frame // so login/signup already paint in the right mode (Ember on Paper / Char). - final theme = await ThemeController.bootstrap(); + // Fall back to a system-brightness controller if persistence fails. + ThemeController theme; + try { + theme = await ThemeController.bootstrap(); + } catch (e, st) { + debugPrint('[main] ThemeController.bootstrap failed, using default: $e\n$st'); + theme = ThemeController.seed( + AppThemeChoice.system, + WidgetsBinding.instance.platformDispatcher.platformBrightness, + ); + } + runApp( MultiProvider( providers: [ @@ -29,3 +44,13 @@ Future main() async { ), ); } + +/// Run an optional startup init, swallowing (but logging) any failure so it can +/// never prevent runApp from being reached. +Future _safeInit(String label, Future Function() init) async { + try { + await init(); + } catch (e, st) { + debugPrint('[main] $label init failed (continuing without it): $e\n$st'); + } +} diff --git a/lib/notify/notification_service.dart b/lib/notify/notification_service.dart index a18c0c6..691428a 100644 --- a/lib/notify/notification_service.dart +++ b/lib/notify/notification_service.dart @@ -52,8 +52,12 @@ class NotificationService { /// Set up the plugin + the device-alerts channel. Idempotent. Does NOT prompt. Future init() async { if (_inited) return; + // Use the real launcher mipmap — the project renamed it to `launcher_icon` + // (see AndroidManifest android:icon), so the Flutter-default `ic_launcher` + // no longer resolves and made initialize() throw `invalid_icon` in release, + // which (being awaited before runApp) blanked the whole app on launch. const AndroidInitializationSettings android = - AndroidInitializationSettings('@mipmap/ic_launcher'); + AndroidInitializationSettings('@mipmap/launcher_icon'); // We prompt explicitly later (after pairing), not at plugin init. const DarwinInitializationSettings darwin = DarwinInitializationSettings( requestAlertPermission: false, diff --git a/pubspec.yaml b/pubspec.yaml index c8e698e..5b87c71 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: openstrap_edge description: "OpenStrap — open-source WHOOP 4.0 companion. BLE drain → raw-first local store → cloud sync." publish_to: 'none' -version: 0.4.5+10 +version: 0.4.6+11 environment: sdk: ^3.11.4 From d04a7e00e9e63a017dec8f4f56d66387cff30123 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 19 Jun 2026 23:23:18 +0530 Subject: [PATCH 33/55] =?UTF-8?q?feat(edge):=20pull-to-refresh=20on=20all?= =?UTF-8?q?=20screens=20=E2=80=94=20MetricScreen=20(Sleep/Heart/Body=20via?= =?UTF-8?q?=20nonce-remount=20refetch),=20SleepDetail=20+=20SleepPeriods?= =?UTF-8?q?=20(await=20=5Fload);=20AlwaysScrollable=20so=20the=20gesture?= =?UTF-8?q?=20fires=20on=20short/empty/error=20content?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/ui/screens/metric_screen.dart | 18 +++++++++++++++--- lib/ui/sleep/sleep_detail_screen.dart | 7 ++++++- lib/ui/sleep/sleep_periods_screen.dart | 9 ++++++++- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/lib/ui/screens/metric_screen.dart b/lib/ui/screens/metric_screen.dart index d30a9b6..e93d4fe 100644 --- a/lib/ui/screens/metric_screen.dart +++ b/lib/ui/screens/metric_screen.dart @@ -45,6 +45,14 @@ class MetricScreen extends StatefulWidget { class _MetricScreenState extends State { int _tab = 0; + int _refresh = 0; // bumped on pull-to-refresh → woven into child keys to force re-fetch + + // Pull-to-refresh: remount the visible child (today detail or the drill bars) so it + // re-fetches its endpoint. The brief await keeps the spinner up while children load. + Future _onRefresh() async { + setState(() => _refresh++); + await Future.delayed(const Duration(milliseconds: 600)); + } @override Widget build(BuildContext context) { @@ -55,7 +63,10 @@ class _MetricScreenState extends State { backgroundColor: AppColors.bg, body: SafeArea( bottom: false, - child: ListView( + child: RefreshIndicator( + onRefresh: _onRefresh, + color: widget.accent, + child: ListView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.symmetric(horizontal: Sp.screen), children: [ @@ -77,10 +88,10 @@ class _MetricScreenState extends State { ), const SizedBox(height: Sp.x5), if (_tab == 0) - widget.todayDetail(context) + KeyedSubtree(key: ValueKey('today-$_refresh'), child: widget.todayDetail(context)) else _DrillLevel( - key: ValueKey('$scale-root'), + key: ValueKey('$scale-$_refresh-root'), title: widget.title, icon: widget.icon, metric: widget.metric, @@ -92,6 +103,7 @@ class _MetricScreenState extends State { ), const SizedBox(height: 110), ], + ), ), ), ); diff --git a/lib/ui/sleep/sleep_detail_screen.dart b/lib/ui/sleep/sleep_detail_screen.dart index 91abb5b..beb187a 100644 --- a/lib/ui/sleep/sleep_detail_screen.dart +++ b/lib/ui/sleep/sleep_detail_screen.dart @@ -308,7 +308,11 @@ class _SleepDetailScreenState extends State { backgroundColor: AppColors.bg, body: SafeArea( bottom: false, - child: ListView( + child: RefreshIndicator( + onRefresh: _load, + color: AppColors.coral, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.symmetric(horizontal: Sp.screen), children: [ const SizedBox(height: Sp.x4), @@ -317,6 +321,7 @@ class _SleepDetailScreenState extends State { ..._sections(), const SizedBox(height: 40), ], + ), ), ), ); diff --git a/lib/ui/sleep/sleep_periods_screen.dart b/lib/ui/sleep/sleep_periods_screen.dart index 764ed4b..1e3ecbf 100644 --- a/lib/ui/sleep/sleep_periods_screen.dart +++ b/lib/ui/sleep/sleep_periods_screen.dart @@ -74,7 +74,13 @@ class _SleepPeriodsScreenState extends State { backgroundColor: AppColors.bg, body: SafeArea( bottom: false, - child: ListView( + child: RefreshIndicator( + onRefresh: _load, + color: AppColors.coral, + child: ListView( + // AlwaysScrollable so pull-to-refresh fires even when content is short + // (loading / empty / error) — the common "refresh doesn't work" cause. + physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.symmetric(horizontal: Sp.screen), children: [ const SizedBox(height: Sp.x4), @@ -104,6 +110,7 @@ class _SleepPeriodsScreenState extends State { ], const SizedBox(height: 40), ], + ), ), ), ); From 4c88ccfb6f18edc8f81f028e4c86e695e8ae6588 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 19 Jun 2026 23:24:48 +0530 Subject: [PATCH 34/55] feat(edge): per-nap hypnogram strip on the sleep-periods screen (renders /day/v2/sleep period.hypnogram; shown only when present) --- lib/ui/sleep/sleep_periods_screen.dart | 53 ++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/lib/ui/sleep/sleep_periods_screen.dart b/lib/ui/sleep/sleep_periods_screen.dart index 1e3ecbf..3eaedab 100644 --- a/lib/ui/sleep/sleep_periods_screen.dart +++ b/lib/ui/sleep/sleep_periods_screen.dart @@ -185,6 +185,12 @@ class _SleepPeriodsScreenState extends State { Text('${_clock(onset)} – ${_clock(wake)}', style: AppText.label.copyWith(color: AppColors.inkSoft)), ], + if (_hypList(p).isNotEmpty) ...[ + const SizedBox(height: Sp.x4), + // Per-nap hypnogram — the same banded timeline as the main sleep, so a + // nap shows its own stage progression (only rendered when present). + _HypnoStrip(segments: _hypList(p), colorOf: _stageColor), + ], if (stages != null) ...[ const SizedBox(height: Sp.x4), _stageBar(stages), @@ -196,6 +202,13 @@ class _SleepPeriodsScreenState extends State { ); } + // Per-period hypnogram points [{t, stage}] from /day/v2/sleep (empty if none). + List> _hypList(Map p) { + final h = p['hypnogram']; + if (h is List) return h.whereType().map((e) => e.cast()).toList(); + return const []; + } + Widget _stageBar(Map s) { final deep = (_num(s['deep_min']) ?? 0).toDouble(); final rem = (_num(s['rem_min']) ?? 0).toDouble(); @@ -289,3 +302,43 @@ class _SleepPeriodsScreenState extends State { ), ]); } + +/// Compact per-nap hypnogram: each downsampled {t, stage} point drawn as a band at +/// its sleep-depth row (awake top → deep bottom). Self-contained; colors injected. +class _HypnoStrip extends StatelessWidget { + final List> segments; + final Color Function(String) colorOf; + const _HypnoStrip({required this.segments, required this.colorOf}); + + @override + Widget build(BuildContext context) => SizedBox( + height: 40, + width: double.infinity, + child: CustomPaint(painter: _HypnoPainter(segments, colorOf)), + ); +} + +class _HypnoPainter extends CustomPainter { + final List> pts; + final Color Function(String) colorOf; + _HypnoPainter(this.pts, this.colorOf); + static const _rank = {'awake': 0, 'rem': 1, 'light': 2, 'deep': 3}; + + @override + void paint(Canvas c, Size s) { + if (pts.length < 2) return; + final n = pts.length; + final bandH = s.height / 4; + final w = s.width / (n - 1); + final paint = Paint(); + for (int i = 0; i < n - 1; i++) { + final st = (pts[i]['stage'] as String?) ?? 'light'; + final r = (_rank[st] ?? 2).toDouble(); + paint.color = colorOf(st); + c.drawRect(Rect.fromLTWH(i * w, r * bandH, w + 0.5, bandH - 1), paint); + } + } + + @override + bool shouldRepaint(_HypnoPainter old) => old.pts != pts; +} From 4c791b9c6926da3fc26d477ee477bd8085bd35d0 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 20 Jun 2026 01:17:21 +0530 Subject: [PATCH 35/55] feat(edge): add lock-screen Band Battery widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New OpenStrapBatteryWidget (lock-screen accessory + home-screen small) showing the band's battery level. Battery is a live BLE value, not in /today, so this widget renders the last App-Group snapshot the app wrote while connected (no network self-refresh; '—' until the band is seen, muted after 24h stale). - Strap glyph (applewatch) that swaps to a bolt while charging - Shows the strap's advertising name instead of a static label - Linear capsule/Gauge bar on the small + rectangular surfaces - WidgetService.pushBattery(pct, charging, name) wired into the device-state hook, gated so it only reloads when values change (the hook fires ~1 Hz on live HR) --- .../OpenStrapBatteryWidget.swift | 266 ++++++++++++++++++ .../OpenStrapWidgetBundle.swift | 1 + lib/state/app_state.dart | 17 ++ lib/widget/widget_service.dart | 23 ++ 4 files changed, 307 insertions(+) create mode 100644 ios/OpenStrapWidget/OpenStrapBatteryWidget.swift diff --git a/ios/OpenStrapWidget/OpenStrapBatteryWidget.swift b/ios/OpenStrapWidget/OpenStrapBatteryWidget.swift new file mode 100644 index 0000000..657645c --- /dev/null +++ b/ios/OpenStrapWidget/OpenStrapBatteryWidget.swift @@ -0,0 +1,266 @@ +// +// OpenStrapBatteryWidget.swift +// OpenStrapWidget +// +// Lock-screen (and home-screen) widget showing the BAND's battery level. +// +// Battery is a live BLE value (GET_BATTERY / HELLO) that only the app knows — +// it is NOT part of /today — so unlike OpenStrapWidget this one does NOT +// self-refresh over the network. It renders the last snapshot the app wrote +// into the shared App Group (keys batt_pct / batt_charging / batt_at) the last +// time the band was connected. "—" until we've ever seen the band. +// +// Primary surface is the lock screen (accessory* families); a systemSmall +// variant is included so it can also live on the home screen. +// + +import WidgetKit +import SwiftUI + +private let kAppGroup = "group.wtf.openstrap" + +// MARK: - Theme (mirrors OpenStrapWidget's Ember-on-Paper / Char) + +private extension Color { + init(_ r: Int, _ g: Int, _ b: Int) { + self.init(red: Double(r) / 255, green: Double(g) / 255, blue: Double(b) / 255) + } +} + +private struct BattPal { + let bg: Color, ink: Color, inkMuted: Color, track: Color + static let light = BattPal(bg: Color(244, 241, 236), ink: Color(26, 23, 20), + inkMuted: Color(165, 156, 144), track: Color(236, 231, 223)) + static let dark = BattPal(bg: Color(30, 26, 21), ink: Color(241, 236, 227), + inkMuted: Color(126, 116, 102), track: Color(42, 37, 31)) + static var current: BattPal { + let isDark = UserDefaults(suiteName: kAppGroup)?.object(forKey: "theme_dark") as? Bool ?? false + return isDark ? .dark : .light + } +} + +private extension Color { + static var battPaper: Color { BattPal.current.bg } + static var battInk: Color { BattPal.current.ink } + static var battInkMuted: Color { BattPal.current.inkMuted } + static var battTrack: Color { BattPal.current.track } + static let battCoral = Color(255, 90, 54) + static let battCoralDeep = Color(232, 67, 31) + static let battGood = Color(43, 182, 115) + static let battCharge = Color(124, 168, 240) +} + +// MARK: - Model + +struct BatteryEntry: TimelineEntry { + let date: Date + let name: String // strap advertising name (falls back to "Strap") + let pct: Int // -1 = never seen the band + let charging: Bool + let updatedAt: Int // epoch seconds, 0 = unknown + let stale: Bool // last reading is old enough that we mute it + + static let placeholder = BatteryEntry( + date: Date(), name: "WHOOP 4.0", pct: 68, charging: false, + updatedAt: Int(Date().timeIntervalSince1970), stale: false) + + var hasData: Bool { pct >= 0 } + var t: Double { pct >= 0 ? min(max(Double(pct) / 100.0, 0), 1) : 0 } + + /// Coral when low, deep-coral when critical, blue while charging, otherwise ink. + var color: Color { + if !hasData { return .battInkMuted } + if charging { return .battCharge } + if pct <= 10 { return .battCoralDeep } + if pct <= 25 { return .battCoral } + return .battGood + } + + var valueText: String { pct >= 0 ? "\(pct)%" : "—" } + + /// Icon: a charging bolt while plugged in, otherwise the strap glyph + /// (mirrors the app's device icon, HugeIcons SmartWatch01). + var symbol: String { charging ? "bolt.fill" : "applewatch" } +} + +// MARK: - Shared store (App Group, read-only here) + +private enum BatteryStore { + static func read() -> BatteryEntry { + let d = UserDefaults(suiteName: kAppGroup) + let pct = d?.object(forKey: "batt_pct") as? Int ?? -1 + let charging = d?.object(forKey: "batt_charging") as? Bool ?? false + let at = d?.object(forKey: "batt_at") as? Int ?? 0 + let raw = (d?.string(forKey: "batt_name") ?? "").trimmingCharacters(in: .whitespaces) + let name = raw.isEmpty ? "Strap" : raw + // Mute (still show the number, but greyed) once the reading is > 24h old — + // we genuinely don't know the current level if we haven't talked to the band. + let stale = at > 0 && (Int(Date().timeIntervalSince1970) - at) > 86_400 + return BatteryEntry(date: Date(), name: name, pct: pct, charging: charging, + updatedAt: at, stale: stale) + } +} + +// MARK: - Provider +// No network refresh — the app pushes new readings + calls reloadAllTimelines. +// We still re-render every ~30 min so the staleness flag can flip on its own. + +struct BatteryProvider: TimelineProvider { + func placeholder(in context: Context) -> BatteryEntry { .placeholder } + + func getSnapshot(in context: Context, completion: @escaping (BatteryEntry) -> Void) { + completion(context.isPreview ? .placeholder : BatteryStore.read()) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let entry = BatteryStore.read() + let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) + ?? Date().addingTimeInterval(1800) + completion(Timeline(entries: [entry], policy: .after(next))) + } +} + +// MARK: - Views + +private func battNumFont(_ size: CGFloat) -> Font { .system(size: size, weight: .bold, design: .rounded) } + +/// Linear capsule progress bar (ember fill on a track). +private struct BattBar: View { + let t: Double + let color: Color + var height: CGFloat = 8 + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(Color.battTrack) + if t > 0 { + Capsule().fill(color) + .frame(width: max(height, geo.size.width * min(max(t, 0), 1))) + } + } + } + .frame(height: height) + } +} + +/// Home-screen small: strap name + level + linear bar. +private struct BatterySmallView: View { + let e: BatteryEntry + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 6) { + Image(systemName: e.symbol).font(.system(size: 14, weight: .semibold)).foregroundColor(e.color) + Text(e.name).font(.system(size: 12, weight: .semibold)).foregroundColor(.battInkMuted) + .lineLimit(1).minimumScaleFactor(0.7) + } + Spacer(minLength: 8) + Text(e.valueText).font(battNumFont(30)).foregroundColor(.battInk) + .minimumScaleFactor(0.6).lineLimit(1) + Spacer(minLength: 8) + BattBar(t: e.t, color: e.color, height: 9) + Text(e.charging ? "Charging" : (e.hasData ? "Battery" : "Not connected")) + .font(.system(size: 10, weight: .medium)).foregroundColor(.battInkMuted) + .padding(.top, 5) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .opacity(e.stale ? 0.5 : 1) + .padding(14) + } +} + +@available(iOSApplicationExtension 16.0, *) +private struct BatteryCircularView: View { + let e: BatteryEntry + var body: some View { + Gauge(value: e.t) { + Image(systemName: e.symbol) + } currentValueLabel: { + Text(e.valueText) + } + .gaugeStyle(.accessoryCircularCapacity) + .widgetAccentable() + } +} + +@available(iOSApplicationExtension 16.0, *) +private struct BatteryRectangularView: View { + let e: BatteryEntry + var body: some View { + VStack(alignment: .leading, spacing: 3) { + Label { + Text(e.name).lineLimit(1) + } icon: { + Image(systemName: e.symbol) + } + .font(.system(size: 13, weight: .semibold)) + .widgetAccentable() + + // Linear lock-screen battery bar with the level inline. + Gauge(value: e.t) { + Text("") + } currentValueLabel: { + Text(e.hasData ? "\(e.pct)%" : "—") + } + .gaugeStyle(.accessoryLinearCapacity) + } + } +} + +private extension View { + @ViewBuilder func battWidgetBackground(_ color: Color) -> some View { + if #available(iOSApplicationExtension 17.0, *) { + containerBackground(color, for: .widget) + } else { + background(color) + } + } +} + +struct OpenStrapBatteryEntryView: View { + @Environment(\.widgetFamily) var family + var entry: BatteryEntry + + var body: some View { + content.battWidgetBackground(family == .systemSmall ? Color.battPaper : Color.clear) + } + + @ViewBuilder private var content: some View { + switch family { + case .systemSmall: BatterySmallView(e: entry) + default: + if #available(iOSApplicationExtension 16.0, *) { + switch family { + case .accessoryCircular: BatteryCircularView(e: entry) + case .accessoryRectangular: BatteryRectangularView(e: entry) + case .accessoryInline: + Label( + entry.hasData ? "\(entry.name) \(entry.pct)%" : "\(entry.name) —", + systemImage: entry.symbol) + default: BatterySmallView(e: entry) + } + } else { + BatterySmallView(e: entry) + } + } + } +} + +struct OpenStrapBatteryWidget: Widget { + let kind: String = "OpenStrapBatteryWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: BatteryProvider()) { entry in + OpenStrapBatteryEntryView(entry: entry) + } + .configurationDisplayName("Band Battery") + .description("Your band's battery level at a glance.") + .supportedFamilies(supportedFamilies) + } + + private var supportedFamilies: [WidgetFamily] { + if #available(iOSApplicationExtension 16.0, *) { + return [.systemSmall, .accessoryCircular, .accessoryRectangular, .accessoryInline] + } + return [.systemSmall] + } +} diff --git a/ios/OpenStrapWidget/OpenStrapWidgetBundle.swift b/ios/OpenStrapWidget/OpenStrapWidgetBundle.swift index d73ed4f..86c2463 100644 --- a/ios/OpenStrapWidget/OpenStrapWidgetBundle.swift +++ b/ios/OpenStrapWidget/OpenStrapWidgetBundle.swift @@ -12,6 +12,7 @@ import SwiftUI struct OpenStrapWidgetBundle: WidgetBundle { var body: some Widget { OpenStrapWidget() + OpenStrapBatteryWidget() OpenStrapWidgetControl() OpenStrapWidgetLiveActivity() } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index f1c1bac..b2e1c21 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -58,6 +58,12 @@ class AppState extends ChangeNotifier { bool _keepAlive = false; bool _reconnecting = false; String _prevConn = 'disconnected'; + // Last battery snapshot pushed to the Band Battery widget — so we only reload + // the widget when pct/charging actually change (the engine-state hook fires + // ~1 Hz on live HR). -2 = never pushed. + int _widgetBattPct = -2; + bool? _widgetBattCharging; + String? _widgetBattName; bool initialized = false; /// True while the app is backgrounded. On iOS we KEEP the BLE connection alive in @@ -343,6 +349,17 @@ class AppState extends ChangeNotifier { void _onEngineState(DeviceState s) { // Battery-low / charging OS notifications (edge-triggered + de-duped inside). _deviceAlerts.onDeviceState(batteryPct: s.batteryPct, charging: s.charging); + // Keep the lock-screen Band Battery widget current — only when it changed. + final battPct = s.batteryPct?.round() ?? -1; + if (battPct != _widgetBattPct || + s.charging != _widgetBattCharging || + s.strapName != _widgetBattName) { + _widgetBattPct = battPct; + _widgetBattCharging = s.charging; + _widgetBattName = s.strapName; + unawaited(WidgetService.pushBattery( + s.batteryPct == null ? null : battPct, s.charging, s.strapName)); + } if (_prevConn != 'disconnected' && s.connection == 'disconnected') { if (_keepAlive && isPaired && !_reconnecting) { _log('Connection dropped — reconnecting…'); diff --git a/lib/widget/widget_service.dart b/lib/widget/widget_service.dart index c5901cc..96808e0 100644 --- a/lib/widget/widget_service.dart +++ b/lib/widget/widget_service.dart @@ -16,6 +16,9 @@ class WidgetService { /// WidgetKit "kind" (Swift) / Android provider class name. static const String _iOSName = 'OpenStrapWidget'; + + /// WidgetKit "kind" for the lock-screen Band Battery widget (Swift). + static const String _batteryIOSName = 'OpenStrapBatteryWidget'; static const String _androidName = 'OpenStrapWidgetProvider'; static bool _inited = false; @@ -59,6 +62,26 @@ class WidgetService { } catch (_) {/* widgets unavailable / not configured yet — ignore */} } + /// Push the band's battery snapshot for the lock-screen Band Battery widget. + /// Battery is a live BLE value (not in /today), so that widget never refreshes + /// over the network — it renders whatever we last wrote here. Call from the + /// device-state hook, but only when pct/charging actually changed (the hook + /// fires ~1 Hz on live HR; reloading the widget every tick is wasteful). + /// Sentinel: pct -1 = never seen the band. [name] is the strap's advertising + /// name (the widget falls back to "Strap" when empty/null). + static Future pushBattery(int? pct, bool? charging, String? name) async { + try { + await init(); + await HomeWidget.saveWidgetData('batt_pct', pct ?? -1); + await HomeWidget.saveWidgetData('batt_charging', charging ?? false); + await HomeWidget.saveWidgetData('batt_name', name ?? ''); + await HomeWidget.saveWidgetData( + 'batt_at', DateTime.now().millisecondsSinceEpoch ~/ 1000); + await HomeWidget.updateWidget( + iOSName: _batteryIOSName, androidName: _androidName); + } catch (_) {/* widgets unavailable / not configured yet — ignore */} + } + /// Tell the iOS widget + Live Activity which appearance the app is rendering /// (Ember on Paper vs Char) so those native surfaces match — including when the /// user overrides the OS in-app. Reloads the home widget immediately; the Live From 6d169256c95dcb2cd8d4044d2017584090df1a51 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 20 Jun 2026 02:42:51 +0530 Subject: [PATCH 36/55] =?UTF-8?q?feat(edge):=20live-HR=20Heart=20tile=20+?= =?UTF-8?q?=20Android=20notification=E2=86=92strap-buzz=20relay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Heart screen: LiveHrTile as the first tile — live BLE HR with a BPM-paced lub-dub heartbeat + pulse ring; honesty-gated (off-wrist/stale/disconnected → "—"). - Notification relay (Android only; invisible on iOS): user-selected installed apps buzz the strap on notification. Built on notification_listener_service + installed_apps; settings live in Profile. Self-heal: re-arm the stream + rebind the system listener on resume and on a timer. - Background survival: a cached long-lived FlutterEngine (EdgeApplication) so the Dart VM + BLE + relay survive the Activity being destroyed on swipe-away; platform channels moved into NativeChannels. NOTE: suspected to regress the band buzz — under test on a release build. deps: notification_listener_service, installed_apps --- android/app/src/main/AndroidManifest.xml | 23 +- .../openstrap_edge/EdgeApplication.kt | 42 +++ .../openstrap/openstrap_edge/MainActivity.kt | 135 +------- .../openstrap_edge/NativeChannels.kt | 131 ++++++++ ios/Podfile.lock | 6 + lib/notify/notification_relay.dart | 217 ++++++++++++ lib/state/app_state.dart | 10 + lib/ui/heart/live_hr_tile.dart | 233 +++++++++++++ .../profile/notification_relay_section.dart | 308 ++++++++++++++++++ lib/ui/profile/profile_screen.dart | 8 + lib/ui/screens/screens.dart | 3 + pubspec.lock | 16 + pubspec.yaml | 2 + 13 files changed, 1009 insertions(+), 125 deletions(-) create mode 100644 android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/EdgeApplication.kt create mode 100644 android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt create mode 100644 lib/notify/notification_relay.dart create mode 100644 lib/ui/heart/live_hr_tile.dart create mode 100644 lib/ui/profile/notification_relay_section.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index eb87ace..e5a4a2d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ - + @@ -17,11 +18,16 @@ + + + + + + + + + phone +// --(standard Heart Rate Service 0x180D, ble_peripheral)--> bike computer. +// +// iOS caveat: BLE peripheral advertising is restricted in the background, so this +// is most reliable with the app in the foreground (screen on) during a ride. + +import 'dart:typed_data'; +import 'package:ble_peripheral/ble_peripheral.dart'; + +class HrBroadcaster { + // SIG-assigned 16-bit UUIDs expanded to the 128-bit base. + static const String _hrService = '0000180D-0000-1000-8000-00805F9B34FB'; + static const String _hrMeasurement = '00002A37-0000-1000-8000-00805F9B34FB'; + static const String _bodySensorLocation = '00002A38-0000-1000-8000-00805F9B34FB'; + + final void Function()? onChange; + HrBroadcaster({this.onChange}); + + bool _ready = false; + bool _advertising = false; + int _subscribers = 0; + + bool get isAdvertising => _advertising; + int get subscribers => _subscribers; + + Future isSupported() async { + try { + return await BlePeripheral.isSupported(); + } catch (_) { + return false; + } + } + + Future _ensureSetup() async { + if (_ready) return; + await BlePeripheral.initialize(); + + BlePeripheral.setCharacteristicSubscriptionChangeCallback( + (String deviceId, String characteristicId, bool isSubscribed, String? name) { + _subscribers += isSubscribed ? 1 : -1; + if (_subscribers < 0) _subscribers = 0; + onChange?.call(); + }, + ); + + await BlePeripheral.addService( + BleService( + uuid: _hrService, + primary: true, + characteristics: [ + // Heart Rate Measurement — notify only. CoreBluetooth REQUIRES a notify + // characteristic to be dynamic (no cached `value`); attaching a value to + // anything but a read-only characteristic throws an NSException and crashes + // the app. So no `value` here — we push updates via updateCharacteristic. + BleCharacteristic( + uuid: _hrMeasurement, + properties: [CharacteristicProperties.notify.index], + permissions: [AttributePermissions.readable.index], + ), + // Body Sensor Location — 0x02 = Wrist (the WHOOP sits on the wrist). + BleCharacteristic( + uuid: _bodySensorLocation, + properties: [CharacteristicProperties.read.index], + permissions: [AttributePermissions.readable.index], + value: Uint8List.fromList([0x02]), + ), + ], + ), + ); + _ready = true; + } + + Future start() async { + await _ensureSetup(); + if (_advertising) return; + await BlePeripheral.startAdvertising( + services: [_hrService], + localName: 'OpenStrap HR', + ); + _advertising = true; + onChange?.call(); + } + + Future stop() async { + if (!_advertising) return; + await BlePeripheral.stopAdvertising(); + _advertising = false; + _subscribers = 0; + onChange?.call(); + } + + /// Push a new HR value to any subscribed central (the bike computer). + Future pushHr(int bpm) async { + if (!_advertising || bpm <= 0) return; + try { + await BlePeripheral.updateCharacteristic( + characteristicId: _hrMeasurement, + value: _encodeHr(bpm), + ); + } catch (_) { + /* transient peripheral hiccup — next tick retries */ + } + } + + // HR Measurement format: a flags byte then the value. flags=0x00 → 8-bit HR, + // sensor-contact "not supported". Valid for any HR < 256. + Uint8List _encodeHr(int bpm) => + Uint8List.fromList([0x00, bpm < 0 ? 0 : (bpm > 255 ? 255 : bpm)]); +} diff --git a/lib/net/api_client.dart b/lib/net/api_client.dart index 8357bdb..8dd28c4 100644 --- a/lib/net/api_client.dart +++ b/lib/net/api_client.dart @@ -346,6 +346,22 @@ class ApiClient { return {if (from != null) 'from': '$from', if (to != null) 'to': '$to'}; } + // ── Strava ─────────────────────────────────────────────────────────────────── + Future> stravaStatus() => _getObj('/strava/status'); + Future> stravaConnect() => _getObj('/strava/connect'); + Future> stravaSync() => _getObj('/strava/sync'); + + Future>> stravaActivities() async { + final r = await _getObj('/strava/activities'); + return ((r['activities'] as List?) ?? const []).cast>(); + } + + Future stravaDisconnect() async { + final resp = + await _authed((h) => _client.post(_u('/strava/disconnect'), headers: h)); + if (resp.statusCode != 200) throw ApiException(resp.statusCode, resp.body); + } + Future> _getObj(String path, [Map? q]) async { final resp = await _authed((h) => _client.get(_u(path, q), headers: h)); if (resp.statusCode != 200) throw ApiException(resp.statusCode, resp.body); diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index fefdd47..eb5ffe8 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -18,6 +18,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../models/app_status.dart'; import '../ble/ble_engine.dart'; +import '../ble/hr_broadcast.dart'; import '../ble/ios_ble_restore.dart'; import '../data/db.dart'; import '../gestures/gesture_settings.dart'; @@ -473,6 +474,14 @@ class AppState extends ChangeNotifier { notifyListeners(); } + /// Vibrate the band briefly so you can locate it ("find my band"). Best-effort; + /// needs a live connection. Sends only the safe haptic-pattern command — never + /// touches flash, optical LEDs, or any guarded opcode. + Future buzzBand() async { + if (!isConnected) throw Exception('Connect to your strap first'); + await engine.buzz(); + } + Future renameStrap(String name) async { if (!isConnected) throw Exception('Connect to your strap first'); await engine.setStrapName(name); @@ -780,6 +789,49 @@ class AppState extends ChangeNotifier { _spotEnabledStreams = false; } + // ── live HR broadcast (phone → bike computer / gym equipment) ──────────────── + // Re-broadcast the band's live HR as a standard BLE Heart Rate Monitor, so a + // Wahoo/Garmin/gym machine pairs with the phone like a chest strap. Best with + // the app foregrounded (iOS limits background BLE peripheral advertising). + HrBroadcaster? _hrBroadcaster; + Timer? _hrBroadcastTimer; + bool _broadcastEnabledStreams = false; + + bool get isBroadcastingHr => _hrBroadcaster?.isAdvertising ?? false; + int get hrBroadcastSubscribers => _hrBroadcaster?.subscribers ?? 0; + int get liveHrBpm => device.liveHr ?? 0; + + Future startHrBroadcast() async { + if (!isConnected) throw Exception('Connect to your strap first'); + _hrBroadcaster ??= HrBroadcaster(onChange: notifyListeners); + if (!await _hrBroadcaster!.isSupported()) { + throw Exception("This phone can't broadcast Bluetooth HR."); + } + // Reuse a running workout's streams; else turn live HR on ourselves. + if (activeWorkout == null) { + await engine.enableLiveStreams(); + _broadcastEnabledStreams = true; + } + await _hrBroadcaster!.start(); + _hrBroadcastTimer?.cancel(); + _hrBroadcastTimer = Timer.periodic(const Duration(seconds: 1), (_) { + final hr = device.liveHr ?? 0; + if (hr > 0) unawaited(_hrBroadcaster!.pushHr(hr)); + }); + notifyListeners(); + } + + Future stopHrBroadcast() async { + _hrBroadcastTimer?.cancel(); + _hrBroadcastTimer = null; + await _hrBroadcaster?.stop(); + if (_broadcastEnabledStreams && activeWorkout == null && !_spotEnabledStreams) { + unawaited(engine.disableLiveStreams()); + } + _broadcastEnabledStreams = false; + notifyListeners(); + } + // ── live session coach ─────────────────────────────────────────────────────── LiveWorkoutState? activeWorkout; Timer? _workoutTimer; diff --git a/lib/ui/broadcast/broadcast_hr_screen.dart b/lib/ui/broadcast/broadcast_hr_screen.dart new file mode 100644 index 0000000..211fe57 --- /dev/null +++ b/lib/ui/broadcast/broadcast_hr_screen.dart @@ -0,0 +1,147 @@ +// Broadcast HR — re-broadcast the band's live heart rate as a standard Bluetooth +// HR monitor so a bike computer (Wahoo/Garmin) or gym machine can read it. + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../state/app_state.dart'; +import '../../theme/theme.dart'; +import '../../theme/tokens.dart'; +import '../kit/kit.dart'; + +class BroadcastHrScreen extends StatelessWidget { + const BroadcastHrScreen({super.key}); + + @override + Widget build(BuildContext context) { + final app = context.watch(); + final on = app.isBroadcastingHr; + final hr = app.liveHrBpm; + final subs = app.hrBroadcastSubscribers; + final connected = app.isConnected; + + return Scaffold( + appBar: AppBar(title: const Text('Broadcast HR')), + body: ListView( + padding: const EdgeInsets.fromLTRB(Sp.screen, Sp.x6, Sp.screen, Sp.x6), + children: [ + Text('Use your band as a\nheart-rate sensor.', style: AppText.display), + const SizedBox(height: Sp.x4), + Text( + "Your phone re-broadcasts the band's live heart rate as a standard " + 'Bluetooth HR monitor. Pair "OpenStrap HR" on your bike computer, ' + 'treadmill, or gym machine — just like a chest strap.', + style: AppText.bodySoft, + ), + const SizedBox(height: Sp.x6), + + ProCard( + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text('Live heart rate', style: AppText.title), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + AppIcon(Ic.heart, + size: 22, + color: on ? AppColors.coralDeep : AppColors.inkSoft), + const SizedBox(width: Sp.x2), + Text(hr > 0 ? '$hr' : '—', + style: AppText.display.copyWith( + color: + on ? AppColors.coralDeep : AppColors.inkSoft)), + const SizedBox(width: 4), + Text('bpm', style: AppText.caption), + ], + ), + ], + ), + const SizedBox(height: Sp.x4), + Container(height: 1, color: AppColors.divider), + const SizedBox(height: Sp.x4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(on ? 'Broadcasting' : 'Off', + style: AppText.bodySoft.copyWith( + color: on ? AppColors.good : AppColors.inkSoft)), + Text( + on + ? (subs > 0 + ? '$subs device${subs == 1 ? '' : 's'} connected' + : 'waiting for a device…') + : '', + style: AppText.caption, + ), + ], + ), + ], + ), + ), + const SizedBox(height: Sp.x6), + + if (!connected) + Padding( + padding: const EdgeInsets.only(bottom: Sp.x4), + child: Text('Connect to your strap first.', + style: AppText.caption.copyWith(color: AppColors.warn)), + ), + + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: connected + ? () async { + try { + if (on) { + await app.stopHrBroadcast(); + } else { + await app.startHrBroadcast(); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$e'))); + } + } + } + : null, + child: Text(on ? 'Stop broadcasting' : 'Start broadcasting'), + ), + ), + const SizedBox(height: Sp.x6), + + ProCard( + color: AppColors.coralSoft, + shadow: const [], + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('On your bike computer', style: AppText.title), + const SizedBox(height: Sp.x3), + Text( + '1. Keep this screen open and the band connected.\n' + '2. On the Wahoo / Garmin: add a new Heart Rate sensor.\n' + '3. Pair with "OpenStrap HR".\n' + '4. Your live HR shows on the head unit.', + style: AppText.bodySoft, + ), + const SizedBox(height: Sp.x3), + Text( + 'iOS pauses Bluetooth broadcasting in the background — keep the ' + 'app open (screen on) during your ride for a stable signal.', + style: AppText.caption.copyWith(color: AppColors.ink), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/profile/profile_screen.dart b/lib/ui/profile/profile_screen.dart index 7b609b4..9d5dae0 100644 --- a/lib/ui/profile/profile_screen.dart +++ b/lib/ui/profile/profile_screen.dart @@ -11,6 +11,8 @@ import '../../theme/theme.dart'; import '../../theme/theme_switcher.dart'; import '../../theme/tokens.dart'; import '../kit/kit.dart'; +import '../broadcast/broadcast_hr_screen.dart'; +import '../strava/strava_screen.dart'; import '../today/step_goal_screen.dart'; import 'gesture_section.dart'; import 'notification_relay_section.dart'; @@ -144,6 +146,22 @@ class ProfileScreen extends StatelessWidget { const SizedBox(height: Sp.x7), + // ── Integrations ───────────────────────────────────────────── + const SectionHeader('Integrations'), + ProCard( + padding: const EdgeInsets.symmetric( + horizontal: Sp.x5, vertical: Sp.x2), + child: DetailRow( + icon: Ic.activity, + label: 'Strava', + value: 'Rides & workouts', + onTap: () => Navigator.of(context) + .push(themedRoute((_) => const StravaScreen())), + ), + ), + + const SizedBox(height: Sp.x7), + // ── Units (local display preference) ───────────────────────── const SectionHeader('Units'), ProCard( @@ -645,6 +663,21 @@ class _DeviceSheet extends StatelessWidget { ), ), const _HairDivider(), + DetailRow( + icon: Ic.bluetooth, + label: 'Find my band', + value: connected ? 'Buzz it' : 'Connect first', + onTap: connected ? () => _findMyBand(context, live) : null, + ), + const _HairDivider(), + DetailRow( + icon: Ic.pulse, + label: 'Broadcast HR', + value: live.isBroadcastingHr ? 'On' : 'To bike computer', + onTap: () => Navigator.push(context, + MaterialPageRoute(builder: (_) => const BroadcastHrScreen())), + ), + const _HairDivider(), DetailRow( icon: Ic.info, label: 'Serial', @@ -710,6 +743,15 @@ class _DeviceSheet extends StatelessWidget { } } + Future _findMyBand(BuildContext context, AppState app) async { + try { + await app.buzzBand(); + if (context.mounted) _snack(context, 'Buzzing your band…'); + } catch (e) { + if (context.mounted) _snack(context, 'Buzz failed: $e'); + } + } + Future _setAlarm(BuildContext context, AppState app) async { final now = DateTime.now(); final picked = await showTimePicker( diff --git a/lib/ui/strava/strava_screen.dart b/lib/ui/strava/strava_screen.dart new file mode 100644 index 0000000..a104162 --- /dev/null +++ b/lib/ui/strava/strava_screen.dart @@ -0,0 +1,258 @@ +// Strava — connect/disconnect, sync, and browse pulled activities (rides incl. +// Wahoo). The OAuth hop happens in the browser; we re-check status on resume. + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../state/app_state.dart'; +import '../../theme/theme.dart'; +import '../../theme/tokens.dart'; +import '../kit/kit.dart'; + +class StravaScreen extends StatefulWidget { + const StravaScreen({super.key}); + @override + State createState() => _StravaScreenState(); +} + +class _StravaScreenState extends State + with WidgetsBindingObserver { + bool _loading = true; + bool _busy = false; + bool _connected = false; + int? _athleteId; + String? _error; + List> _activities = const []; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _refresh(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // Returning from the Strava authorize page in the browser → re-check status. + if (state == AppLifecycleState.resumed) _refresh(); + } + + Future _refresh() async { + final api = context.read().api; + if (api == null) { + setState(() { + _loading = false; + _error = 'Sign in first.'; + }); + return; + } + try { + final st = await api.stravaStatus(); + final connected = st['connected'] == true; + final acts = connected ? await api.stravaActivities() : >[]; + if (!mounted) return; + setState(() { + _loading = false; + _connected = connected; + _athleteId = (st['athlete_id'] as num?)?.toInt(); + _activities = acts; + _error = null; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _error = '$e'; + }); + } + } + + Future _run(Future Function() action) async { + setState(() => _busy = true); + try { + await action(); + } catch (e) { + if (mounted) _snack('$e'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _connect() => _run(() async { + final api = context.read().api; + final r = await api!.stravaConnect(); + final url = r['url'] as String?; + if (url != null) { + await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + if (mounted) _snack('Authorize in your browser, then come back.'); + } + }); + + Future _sync() => _run(() async { + final api = context.read().api; + final r = await api!.stravaSync(); + _snack('Synced: ${r['pulled'] ?? 0} in, ${r['pushed'] ?? 0} out.'); + await _refresh(); + }); + + Future _disconnect() => _run(() async { + await context.read().api!.stravaDisconnect(); + await _refresh(); + }); + + void _snack(String m) => + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(m))); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Strava')), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : RefreshIndicator( + onRefresh: _refresh, + child: ListView( + padding: const EdgeInsets.fromLTRB( + Sp.screen, Sp.x6, Sp.screen, Sp.x6), + children: [ + ProCard( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Connection', style: AppText.title), + const SizedBox(height: 2), + Text( + _connected + ? 'Connected${_athleteId != null ? ' · #$_athleteId' : ''}' + : 'Not connected', + style: AppText.bodySoft.copyWith( + color: _connected + ? AppColors.good + : AppColors.inkSoft), + ), + ], + ), + ), + if (_busy) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)), + ], + ), + ), + const SizedBox(height: Sp.x4), + + if (!_connected) + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _busy ? null : _connect, + child: const Text('Connect Strava'), + ), + ) + else + Row( + children: [ + Expanded( + child: FilledButton( + onPressed: _busy ? null : _sync, + child: const Text('Sync now'), + ), + ), + const SizedBox(width: Sp.x3), + Expanded( + child: OutlinedButton( + onPressed: _busy ? null : _disconnect, + child: const Text('Disconnect'), + ), + ), + ], + ), + + if (_error != null) ...[ + const SizedBox(height: Sp.x4), + Text(_error!, + style: AppText.caption.copyWith(color: AppColors.bad)), + ], + + const SizedBox(height: Sp.x6), + + if (!_connected) + Text( + 'Bring your rides (including Wahoo) into OpenStrap, and push ' + 'your other workouts to Strava. Your bike rides are never ' + 'duplicated.', + style: AppText.bodySoft, + ) + else ...[ + Text('Recent activities', style: AppText.title), + const SizedBox(height: Sp.x3), + if (_activities.isEmpty) + Text('No activities yet — tap “Sync now”.', + style: AppText.bodySoft) + else + ..._activities.map(_activityTile), + ], + ], + ), + ), + ); + } + + Widget _activityTile(Map a) { + final ts = (a['start_ts'] as num?)?.toInt(); + final date = + ts != null ? DateTime.fromMillisecondsSinceEpoch(ts * 1000).toLocal() : null; + final dateStr = date != null ? '${date.day}.${date.month}.' : ''; + final km = ((a['distance_m'] as num?)?.toDouble() ?? 0) / 1000; + final mins = (((a['elapsed_sec'] as num?)?.toInt() ?? 0) / 60).round(); + final avgHr = (a['avg_hr'] as num?)?.round(); + final type = (a['type'] ?? 'Activity').toString(); + final name = (a['name'] ?? type).toString(); + + final meta = [ + if (dateStr.isNotEmpty) dateStr, + type, + if (km > 0.1) '${km.toStringAsFixed(1)} km', + if (mins > 0) '$mins min', + if (avgHr != null) 'HR $avgHr', + ].join(' · '); + + return Padding( + padding: const EdgeInsets.only(bottom: Sp.x3), + child: ProCard( + child: Row( + children: [ + AppIcon(Ic.activity, size: 22, color: AppColors.coralDeep), + const SizedBox(width: Sp.x3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, + style: AppText.title, + maxLines: 1, + overflow: TextOverflow.ellipsis), + const SizedBox(height: 2), + Text(meta, style: AppText.bodySoft), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index a4eec24..aa04d16 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,6 +14,10 @@ dependencies: # BLE — flutter_blue_plus is the maintained cross-platform GATT client. flutter_blue_plus: ^1.35.5 + # BLE peripheral mode — lets the phone re-broadcast the band's live HR as a + # standard Heart Rate Service (0x180D) so bike computers / gym kit can read it. + ble_peripheral: ^2.0.0 + # Local raw-first storage. sqflite: ^2.4.1 path: ^1.9.0