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 }} diff --git a/.gitignore b/.gitignore index 2b745c2..6713e23 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ app.*.map.json # secrets / build-time env .env .env.local +ios/Config/Signing.xcconfig # stray dumps / archives never belong in assets *.gz diff --git a/README.md b/README.md index 4791893..ddd6412 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ catches up. It's allowed to miss; it's built to not care. | `lib/state/` | `AppState`, the one source of truth | | `lib/ui/` | every screen: today, sleep, activity, the live workout, recovery, stress, trends, journal, coach, records, notifications, the shareable recap, profile, onboarding | | `lib/widget/`, `lib/live/` | the home-screen widget and the iOS Live Activity bridges | -| `ios/OpenStrapWidget/` | the actual widget and Dynamic Island Live Activity (needs the `group.wtf.openstrap` App Group) | +| `ios/OpenStrapWidget/` | the actual widget and Dynamic Island Live Activity (needs an App Group you configure for your Apple team) | ## Running it @@ -152,8 +152,12 @@ at a time. The CI workflow builds a release APK and pulls the backend URL from a repo secret. -On iOS, the widget and Live Activity need the App Group set up, `NSSupportsLiveActivities` -turned on, and the background task id registered. +On iOS, the widget and Live Activity need signing configured for your Apple team +and a matching App Group. See `guides/IOS_INSTALLATION.md`; the repo ships with +placeholder bundle IDs and App Group values plus a gitignored local signing +override, so it is not tied to one developer account. +Use Profile or Release builds for normal iPhone home-screen relaunch testing; +Flutter Debug builds must be launched through Flutter tooling or Xcode. ## Your backend, or mine @@ -166,6 +170,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-v2.abdulsaheel81.workers.dev + ## The stack, briefly `flutter_blue_plus` for Bluetooth, `sqflite` for the local store, `http` and `provider` @@ -174,4 +180,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 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/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4333f36..e5a4a2d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ - + @@ -9,12 +10,24 @@ android:maxSdkVersion="30" /> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + = 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..312e576 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 @@ -2,4 +2,17 @@ package wtf.openstrap.openstrap_edge import io.flutter.embedding.android.FlutterActivity -class MainActivity : FlutterActivity() +/** + * Attaches to the long-lived engine pre-warmed in [EdgeApplication] rather than spinning + * up its own, and refuses to destroy that engine when the Activity is finished. Combined + * with the EdgeTracking foreground service keeping the process alive, this lets the Dart + * side (BLE connection + notification relay) keep running after the app is swiped from + * recents — instead of Android tearing the engine down (onDetachedFromEngine). + * + * Platform channels are registered on the engine in EdgeApplication (NativeChannels), not + * here, so they exist even while no Activity is attached (headless channel calls work). + */ +class MainActivity : FlutterActivity() { + override fun getCachedEngineId(): String = EdgeApplication.ENGINE_ID + override fun shouldDestroyEngineWithHost(): Boolean = false +} diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt new file mode 100644 index 0000000..ba46fee --- /dev/null +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt @@ -0,0 +1,131 @@ +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.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel + +/** + * Platform channels for the app. Registered on the long-lived engine at creation time + * (see EdgeApplication) so they keep working when Dart runs headless (no Activity). All + * use the application Context — none of these actions need an Activity. + */ +object NativeChannels { + private const val EDGE_TRACKING_CHANNEL = "openstrap/edge_tracking" + private const val DEVICE_ACTIONS_CHANNEL = "openstrap/device_actions" + + private var torchOn = false + + fun register(engine: FlutterEngine, context: Context) { + val app = context.applicationContext + + MethodChannel(engine.dartExecutor.binaryMessenger, EDGE_TRACKING_CHANNEL) + .setMethodCallHandler { call, result -> + when (call.method) { + "start" -> { + val intent = Intent(app, EdgeTrackingService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + app.startForegroundService(intent) + } else { + app.startService(intent) + } + result.success(null) + } + "stop" -> { + app.stopService(Intent(app, EdgeTrackingService::class.java)) + result.success(null) + } + else -> result.notImplemented() + } + } + + // Band-gesture actions. All no-risk OS APIs: media-key dispatch (works for any + // player, no permission), system media volume, ringtone + vibrate, torch. + MethodChannel(engine.dartExecutor.binaryMessenger, DEVICE_ACTIONS_CHANNEL) + .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(app, call.argument("action") ?: "")) + else -> result.notImplemented() + } + } + } + + private fun perform(ctx: Context, action: String): Boolean { + return try { + when (action) { + "media_play_pause" -> dispatchMediaKey(ctx, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE) + "media_next" -> dispatchMediaKey(ctx, KeyEvent.KEYCODE_MEDIA_NEXT) + "media_prev" -> dispatchMediaKey(ctx, KeyEvent.KEYCODE_MEDIA_PREVIOUS) + "volume_up" -> adjustVolume(ctx, AudioManager.ADJUST_RAISE) + "volume_down" -> adjustVolume(ctx, AudioManager.ADJUST_LOWER) + "ring_phone" -> ringPhone(ctx) + "torch" -> toggleTorch(ctx) + else -> return false + } + true + } catch (e: Exception) { + false + } + } + + private fun audio(ctx: Context): AudioManager = + ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager + + private fun dispatchMediaKey(ctx: Context, keyCode: Int) { + val am = audio(ctx) + am.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, keyCode)) + am.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_UP, keyCode)) + } + + private fun adjustVolume(ctx: Context, direction: Int) { + audio(ctx).adjustStreamVolume( + AudioManager.STREAM_MUSIC, direction, AudioManager.FLAG_SHOW_UI + ) + } + + private fun ringPhone(ctx: Context) { + val uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE) + RingtoneManager.getRingtone(ctx.applicationContext, uri)?.play() + vibrate(ctx) + } + + // Torch via CameraManager.setTorchMode — no CAMERA permission required (API 23+). + private fun toggleTorch(ctx: Context) { + val cm = ctx.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(ctx: Context) { + val vibrator: Vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + (ctx.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager).defaultVibrator + } else { + ctx.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/android/app/src/main/res/xml/filepaths.xml b/android/app/src/main/res/xml/filepaths.xml new file mode 100644 index 0000000..ef1dd80 --- /dev/null +++ b/android/app/src/main/res/xml/filepaths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/guides/IOS_INSTALLATION.md b/guides/IOS_INSTALLATION.md new file mode 100644 index 0000000..8bb1ed4 --- /dev/null +++ b/guides/IOS_INSTALLATION.md @@ -0,0 +1,162 @@ +# iOS Installation + +This guide covers the iOS setup needed to build and run Edge from a public +checkout. The project uses widgets, Live Activities, and an App Group, so every +developer needs Apple identifiers that belong to their own Apple Developer +account. + +## Prerequisites + +- Flutter installed and available on `PATH`. +- Xcode installed with iOS support. +- CocoaPods installed. +- An Apple Developer account or team that can create App IDs and App Groups. +- A physical iPhone for BLE testing. The simulator is useful for UI work, but it + cannot test the WHOOP Bluetooth integration. + +Check the local toolchain: + +```bash +flutter doctor -v +flutter devices +``` + +## Project Setup + +From the repository root: + +```bash +cp .env.example .env +flutter pub get +``` + +Edit `.env` and set your backend URL: + +```text +BACKEND_URL=https://your-backend.example +``` + +The app can ask for a backend URL during onboarding if no value is provided, but +using `.env` keeps local builds repeatable. + +## Local iOS Signing Config + +The committed iOS project uses placeholder identifiers so the public repo does +not depend on one developer's Apple account. Personal signing values belong in +`ios/Config/Signing.xcconfig`, which is ignored by git. + +Create your local signing override: + +```bash +cp ios/Config/Signing.xcconfig.example ios/Config/Signing.xcconfig +``` + +Edit `ios/Config/Signing.xcconfig`: + +```text +APP_BUNDLE_IDENTIFIER = com.yourname.openstrapEdge +APP_WIDGET_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).OpenStrapWidget +APP_GROUP_IDENTIFIER = group.com.yourname.openstrap +APPLE_DEVELOPMENT_TEAM = YOURTEAMID +``` + +Default committed values live in `ios/Config/Signing.defaults.xcconfig`: + +```text +APP_BUNDLE_IDENTIFIER = com.example.openstrapEdge +APP_WIDGET_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).OpenStrapWidget +APP_GROUP_IDENTIFIER = group.com.example.openstrap +APPLE_DEVELOPMENT_TEAM = +``` + +Do not commit `ios/Config/Signing.xcconfig`. + +## Apple Developer Setup + +Create matching identifiers in Apple Developer: + +1. App ID for `APP_BUNDLE_IDENTIFIER`. +2. App ID for `APP_WIDGET_BUNDLE_IDENTIFIER`. +3. App Group for `APP_GROUP_IDENTIFIER`. +4. Enable App Groups on both App IDs. +5. Attach the same `APP_GROUP_IDENTIFIER` to both App IDs. + +The entitlement files use `$(APP_GROUP_IDENTIFIER)`, and the native widget reads +the same value from its generated Info.plist. Dart reads the App Group value from +iOS at runtime through `openstrap/ios_config`, so Xcode builds do not need a +separate `--dart-define` to keep the widget bridge aligned. + +## Xcode Setup + +Open the workspace, not the project: + +```bash +open ios/Runner.xcworkspace +``` + +In Xcode: + +1. Select the `Runner` project. +2. Verify signing for the `Runner` target. +3. Verify signing for the `OpenStrapWidget` target. +4. Confirm both targets show the same App Group capability. +5. Select your physical iPhone as the run destination. + +If Xcode changes `ios/Runner.xcodeproj/project.pbxproj` while you are adjusting +personal signing settings, do not commit those personal changes. Put the values +in `ios/Config/Signing.xcconfig` instead and revert the project file. + +## Build and Run + +For a normal development run attached to Flutter tooling: + +```bash +flutter run -d --dart-define-from-file=.env +``` + +For a no-codesign build check: + +```bash +flutter build ios --release --no-codesign +``` + +For a signed release-style device install: + +```bash +flutter run --release -d --dart-define-from-file=.env +``` + +## Debug Builds and Home-Screen Relaunch + +Flutter debug builds on iOS must be launched by Flutter tooling or Xcode. If you +install a Debug build, close it, and later tap the app icon from the iPhone home +screen, Flutter can terminate during engine startup with: + +```text +Cannot create a FlutterEngine instance in debug mode without Flutter tooling or Xcode. +``` + +Use Debug when you are attached to Xcode or `flutter run`. Use Profile or Release +when you want to test normal home-screen launch, close, and relaunch behavior: + +```bash +flutter run --profile -d --dart-define-from-file=.env +flutter run --release -d --dart-define-from-file=.env +``` + +In Xcode, keep the shared scheme's Run action on Debug for development. If you +need home-screen relaunch testing from an Xcode-installed build, temporarily set +Product > Scheme > Edit Scheme > Run > Build Configuration to Profile or Release +locally. + +## Common Issues + +- Open `ios/Runner.xcworkspace`, not `ios/Runner.xcodeproj`. +- If App Group signing fails, verify both App IDs have the same App Group + enabled in Apple Developer. +- If widgets cannot read app data, verify `APP_GROUP_IDENTIFIER` is identical in + Apple Developer and `ios/Config/Signing.xcconfig`. +- If a physical iPhone is on a newer iOS version than your installed Xcode + supports, update Xcode or install the matching iOS support/runtime. +- Quit the official WHOOP app before connecting the band. Bluetooth only lets + one app own the band at a time. diff --git a/ios/Config/Signing.defaults.xcconfig b/ios/Config/Signing.defaults.xcconfig new file mode 100644 index 0000000..ec23a24 --- /dev/null +++ b/ios/Config/Signing.defaults.xcconfig @@ -0,0 +1,7 @@ +// Public defaults. Keep personal Apple signing values in Signing.xcconfig. +APP_BUNDLE_IDENTIFIER = com.example.openstrapEdge +APP_WIDGET_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).OpenStrapWidget +APP_GROUP_IDENTIFIER = group.com.example.openstrap +APPLE_DEVELOPMENT_TEAM = + +#include? "Signing.xcconfig" diff --git a/ios/Config/Signing.xcconfig.example b/ios/Config/Signing.xcconfig.example new file mode 100644 index 0000000..b95f493 --- /dev/null +++ b/ios/Config/Signing.xcconfig.example @@ -0,0 +1,6 @@ +// Copy to Signing.xcconfig and replace with identifiers from your Apple account. +// Signing.xcconfig is gitignored and should not be committed. +APP_BUNDLE_IDENTIFIER = com.yourname.openstrapEdge +APP_WIDGET_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).OpenStrapWidget +APP_GROUP_IDENTIFIER = group.com.yourname.openstrap +APPLE_DEVELOPMENT_TEAM = YOURTEAMID diff --git a/ios/OpenStrapWidget/AppGroup.swift b/ios/OpenStrapWidget/AppGroup.swift new file mode 100644 index 0000000..a1d07ac --- /dev/null +++ b/ios/OpenStrapWidget/AppGroup.swift @@ -0,0 +1,8 @@ +import Foundation + +enum AppGroup { + static let identifier: String = { + Bundle.main.object(forInfoDictionaryKey: "OpenStrapAppGroupIdentifier") as? String + ?? "group.com.example.openstrap" + }() +} diff --git a/ios/OpenStrapWidget/Info.plist b/ios/OpenStrapWidget/Info.plist index 0f118fb..300cee4 100644 --- a/ios/OpenStrapWidget/Info.plist +++ b/ios/OpenStrapWidget/Info.plist @@ -2,6 +2,8 @@ + OpenStrapAppGroupIdentifier + $(APP_GROUP_IDENTIFIER) NSExtension NSExtensionPointIdentifier 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/OpenStrapWidget.entitlements b/ios/OpenStrapWidget/OpenStrapWidget.entitlements index 15c952a..d9849a8 100644 --- a/ios/OpenStrapWidget/OpenStrapWidget.entitlements +++ b/ios/OpenStrapWidget/OpenStrapWidget.entitlements @@ -4,7 +4,7 @@ com.apple.security.application-groups - group.wtf.openstrap + $(APP_GROUP_IDENTIFIER) diff --git a/ios/OpenStrapWidget/OpenStrapWidget.swift b/ios/OpenStrapWidget/OpenStrapWidget.swift index 9861b86..b24bfa3 100644 --- a/ios/OpenStrapWidget/OpenStrapWidget.swift +++ b/ios/OpenStrapWidget/OpenStrapWidget.swift @@ -7,29 +7,47 @@ // /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 -private let kAppGroup = "group.wtf.openstrap" +private let kAppGroup = AppGroup.identifier -// 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 func scoreColor(_ t: Double) -> Color { - if t >= 0.75 { return .good } - if t >= 0.5 { return .coral } - return .coralDeep +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 @@ -37,16 +55,42 @@ private func scoreColor(_ t: Double) -> Color { struct OpenStrapEntry: TimelineEntry { let date: Date let hasData: Bool - let readiness: Int // -1 = none + let readiness: Int // -1 = none (composite 0..100) — the headline 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, 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 { + 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) @@ -63,6 +107,8 @@ private enum Store { 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") ?? "") } @@ -74,6 +120,8 @@ private enum Store { 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 +165,15 @@ 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 +182,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, readiness: readiness, strain: strain, + sleepMin: sleepMin, needMin: needMin, hrv: hrv, + hrvBaseline: hrvBase, rhr: rhr, coachLine: coachLine) } } @@ -186,69 +237,100 @@ 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, 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 { - VStack(spacing: 4) { - ZStack { - Ring(t: t, color: color, lineWidth: 6) - Text(value).font(numFont(15)).foregroundColor(.ink) - } - .frame(width: 50, height: 50) - Text(label).font(.system(size: 9, weight: .semibold)).tracking(0.8).foregroundColor(.inkMuted) + 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) } } -private struct MediumView: 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 { - 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) { + HStack(spacing: 12) { 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) - } + Ring(t: e.readinessT, color: e.readinessColor, lineWidth: 9) + Text(e.readiness >= 0 ? "\(e.readiness)" : "—").font(numFont(22)).foregroundColor(e.readinessColor) } - .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) - } + .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) { + 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) + } +} + +private struct MediumView: View { + let e: OpenStrapEntry + var body: some View { + VStack(alignment: .leading, spacing: 12) { + ReadinessRow(e: e) + TripleRings(e: e, size: 56, line: 7, valueSize: 15) + } + .frame(maxWidth: .infinity, alignment: .leading) .padding(16) } } @@ -257,8 +339,8 @@ 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.readinessT) { + Text("RDY") } currentValueLabel: { Text(e.readiness >= 0 ? "\(e.readiness)" : "—") } @@ -272,8 +354,8 @@ 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("Recovery \(e.readiness >= 0 ? "\(e.readiness)" : "—") Strain \(e.strain >= 0 ? String(format: "%.1f", e.strain) : "—")") + 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)" : "")) .font(.system(size: 12)).foregroundStyle(.secondary) @@ -311,7 +393,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("Ready \(entry.readiness >= 0 ? "\(entry.readiness)" : "—") · Strain \(entry.strain >= 0 ? String(format: "%.1f", entry.strain) : "—")") default: SmallView(e: entry) } } else { @@ -329,7 +411,7 @@ struct OpenStrapWidget: Widget { OpenStrapWidgetEntryView(entry: entry) } .configurationDisplayName("OpenStrap") - .description("Your recovery, strain and sleep at a glance.") + .description("Readiness, strain, sleep and HRV at a glance.") .supportedFamilies(supportedFamilies) } 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/ios/OpenStrapWidget/OpenStrapWidgetControl.swift b/ios/OpenStrapWidget/OpenStrapWidgetControl.swift index d392874..3f086fe 100644 --- a/ios/OpenStrapWidget/OpenStrapWidgetControl.swift +++ b/ios/OpenStrapWidget/OpenStrapWidgetControl.swift @@ -12,7 +12,7 @@ import WidgetKit struct OpenStrapWidgetControl: ControlWidget { var body: some ControlWidgetConfiguration { StaticControlConfiguration( - kind: "wtf.openstrap.openstrapEdge.OpenStrapWidget", + kind: Bundle.main.bundleIdentifier ?? "OpenStrapWidget", provider: Provider() ) { value in ControlWidgetToggle( diff --git a/ios/OpenStrapWidget/OpenStrapWidgetLiveActivity.swift b/ios/OpenStrapWidget/OpenStrapWidgetLiveActivity.swift index 99f4f33..7675143 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 = AppGroup.identifier + +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 { @@ -111,7 +136,7 @@ private func hrText(_ v: Int) -> String { v > 0 ? "\(v)" : "—" } struct EndSessionIntent: LiveActivityIntent { static var title: LocalizedStringResource = "Finish session" func perform() async throws -> some IntentResult { - UserDefaults(suiteName: "group.wtf.openstrap")?.set(true, forKey: "end_session") + UserDefaults(suiteName: kAppGroup)?.set(true, forKey: "end_session") for activity in Activity.activities { await activity.end(nil, dismissalPolicy: .immediate) } diff --git a/ios/OpenStrapWidgetExtension.entitlements b/ios/OpenStrapWidgetExtension.entitlements index 15c952a..d9849a8 100644 --- a/ios/OpenStrapWidgetExtension.entitlements +++ b/ios/OpenStrapWidgetExtension.entitlements @@ -4,7 +4,7 @@ com.apple.security.application-groups - group.wtf.openstrap + $(APP_GROUP_IDENTIFIER) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index dc46627..87b433c 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3,8 +3,15 @@ PODS: - flutter_blue_plus_darwin (0.0.2): - Flutter - FlutterMacOS + - flutter_local_notifications (0.0.1): + - Flutter + - flutter_secure_storage_darwin (10.0.0): + - Flutter + - 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,42 +20,54 @@ PODS: - sqflite_darwin (0.0.4): - Flutter - FlutterMacOS - - workmanager_apple (0.0.1): + - url_launcher_ios (0.0.1): - Flutter DEPENDENCIES: - Flutter (from `Flutter`) - flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`) + - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) + - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_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`) - - workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) EXTERNAL SOURCES: Flutter: :path: Flutter flutter_blue_plus_darwin: :path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin" + flutter_local_notifications: + :path: ".symlinks/plugins/flutter_local_notifications/ios" + flutter_secure_storage_darwin: + :path: ".symlinks/plugins/flutter_secure_storage_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" - workmanager_apple: - :path: ".symlinks/plugins/workmanager_apple/ios" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 + flutter_local_notifications: 395056b3175ba4f08480a7c5de30cd36d69827e4 + flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 - workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778 + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b PODFILE CHECKSUM: d356a3c7ef9fe1586fd8829c4e39f9dee09401b4 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index d915d08..0a79040 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -80,6 +80,8 @@ 534897572FDC1A610033A4D9 /* OpenStrapWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenStrapWidgetExtension.entitlements; sourceTree = ""; }; 534897A62FDC2B310033A4D9 /* LiveActivityBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityBridge.swift; sourceTree = ""; }; 606824C7302BC5108CEC40DF /* BleRestoreManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BleRestoreManager.swift; sourceTree = ""; }; + 6A2B37C42FDD000100000001 /* Signing.defaults.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Signing.defaults.xcconfig; sourceTree = ""; }; + 6A2B37C52FDD000100000001 /* Signing.xcconfig.example */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Signing.xcconfig.example; sourceTree = ""; }; 73008EEA61B58B119FFFF6D8 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -172,6 +174,15 @@ name = Frameworks; sourceTree = ""; }; + 6A2B37C32FDD000100000001 /* Config */ = { + isa = PBXGroup; + children = ( + 6A2B37C42FDD000100000001 /* Signing.defaults.xcconfig */, + 6A2B37C52FDD000100000001 /* Signing.xcconfig.example */, + ); + path = Config; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -188,6 +199,7 @@ children = ( 534897A62FDC2B310033A4D9 /* LiveActivityBridge.swift */, 534897572FDC1A610033A4D9 /* OpenStrapWidgetExtension.entitlements */, + 6A2B37C32FDD000100000001 /* Config */, 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 534897402FDC19C80033A4D9 /* OpenStrapWidget */, @@ -537,6 +549,7 @@ /* Begin XCBuildConfiguration section */ 249021D3217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 6A2B37C42FDD000100000001 /* Signing.defaults.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; @@ -595,14 +608,14 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 2U62X3RF3R; + DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = wtf.openstrap.openstrapEdge; + PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -619,7 +632,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = wtf.openstrap.openstrapEdge.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -637,7 +650,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = wtf.openstrap.openstrapEdge.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -653,7 +666,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = wtf.openstrap.openstrapEdge.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -675,13 +688,14 @@ CODE_SIGN_ENTITLEMENTS = OpenStrapWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 2U62X3RF3R; + DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenStrapWidget/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = OpenStrapWidget; INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_OpenStrapAppGroupIdentifier = "$(APP_GROUP_IDENTIFIER)"; IPHONEOS_DEPLOYMENT_TARGET = 26.5; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -692,7 +706,7 @@ MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = wtf.openstrap.openstrapEdge.OpenStrapWidget; + PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -721,13 +735,14 @@ CODE_SIGN_ENTITLEMENTS = OpenStrapWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 2U62X3RF3R; + DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenStrapWidget/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = OpenStrapWidget; INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_OpenStrapAppGroupIdentifier = "$(APP_GROUP_IDENTIFIER)"; IPHONEOS_DEPLOYMENT_TARGET = 26.5; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -737,7 +752,7 @@ LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = wtf.openstrap.openstrapEdge.OpenStrapWidget; + PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -764,13 +779,14 @@ CODE_SIGN_ENTITLEMENTS = OpenStrapWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 2U62X3RF3R; + DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenStrapWidget/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = OpenStrapWidget; INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_OpenStrapAppGroupIdentifier = "$(APP_GROUP_IDENTIFIER)"; IPHONEOS_DEPLOYMENT_TARGET = 26.5; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -780,7 +796,7 @@ LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = wtf.openstrap.openstrapEdge.OpenStrapWidget; + PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -794,6 +810,7 @@ }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 6A2B37C42FDD000100000001 /* Signing.defaults.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; @@ -851,6 +868,7 @@ }; 97C147041CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 6A2B37C42FDD000100000001 /* Signing.defaults.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; @@ -911,14 +929,14 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 2U62X3RF3R; + DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = wtf.openstrap.openstrapEdge; + PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -935,14 +953,14 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 2U62X3RF3R; + DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = wtf.openstrap.openstrapEdge; + PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index bce4f79..20da3be 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,7 +1,7 @@ import Flutter import UIKit -// workmanager 0.9 split the iOS plugin into the `workmanager_apple` module. -import workmanager_apple +import AudioToolbox +import AVFoundation @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { @@ -9,15 +9,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. @@ -37,5 +31,78 @@ import workmanager_apple 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()) + } + // Build-time iOS configuration exposed to Dart without requiring --dart-define. + if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "ConfigBridge") { + ConfigBridge.register(messenger: registrar.messenger()) + } + } +} + +enum ConfigBridge { + private static let channelName = "openstrap/ios_config" + + static func register(messenger: FlutterBinaryMessenger) { + let channel = FlutterMethodChannel(name: channelName, binaryMessenger: messenger) + channel.setMethodCallHandler { call, result in + switch call.method { + case "appGroupIdentifier": + result(Bundle.main.object(forInfoDictionaryKey: "OpenStrapAppGroupIdentifier") as? String ?? "") + default: + result(FlutterMethodNotImplemented) + } + } + } +} + +// 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/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..08db5a0 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -4,11 +4,8 @@ NSSupportsLiveActivities - BGTaskSchedulerPermittedIdentifiers - - openstrap.periodicSync - be.tramckrijte.workmanager.BackgroundTask - + OpenStrapAppGroupIdentifier + $(APP_GROUP_IDENTIFIER) CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion @@ -63,10 +60,7 @@ UIBackgroundModes bluetooth-central - processing - fetch bluetooth-peripheral - remote-notification UILaunchStoryboardName LaunchScreen diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements index 15c952a..d9849a8 100644 --- a/ios/Runner/Runner.entitlements +++ b/ios/Runner/Runner.entitlements @@ -4,7 +4,7 @@ com.apple.security.application-groups - group.wtf.openstrap + $(APP_GROUP_IDENTIFIER) diff --git a/lib/app.dart b/lib/app.dart index 1f9bf0f..0255b17 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -4,15 +4,16 @@ 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/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/screens/screens.dart'; +import 'ui/workouts/workouts_screen.dart'; +import 'ui/activity/live_session_screen.dart'; class OpenStrapApp extends StatefulWidget { const OpenStrapApp({super.key}); @@ -33,21 +34,38 @@ 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(); if (state == AppLifecycleState.resumed) { - final app = context.read(); 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 + // in the background (no-op on Android, where the foreground service holds it). + app.pauseForBackground(); } } @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(), ); } @@ -58,17 +76,33 @@ class _Gate extends StatelessWidget { const _Gate(); @override Widget build(BuildContext context) { - final app = context.watch(); - if (!app.initialized) { - return const Scaffold( - body: Center(child: CircularProgressIndicator(color: AppColors.coral)), - ); + // 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(); + // 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(); } - 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(); } } @@ -82,20 +116,24 @@ class _ShellState extends State<_Shell> { final _controller = PageController(); int _index = 0; - static const _pages = [ - TodayScreen(), - SleepScreen(), - ActivityScreen(), - TrendsScreen(), - ProfileScreen(), - ]; + // 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'), (Ic.sleep, 'Sleep'), - (Ic.activity, 'Activity'), - (Ic.stats, 'Stats'), - (Ic.profile, 'You'), + (Ic.heart, 'Heart'), + (Ic.strain, 'Body'), + (Ic.run, 'Workouts'), ]; @override @@ -119,11 +157,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(themedRoute( + (_) => 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: 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(), + 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/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 29687de..2aeaceb 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -82,6 +82,14 @@ class BleEngine { bool _liveEnabled = false; Timer? _heartbeat; + // Wall-clock of the last BLE notification received on ANY characteristic. iOS can + // resume the app with the peripheral still flagged "connected" while its GATT + // notifications silently died during suspension (they don't auto-resume) — the UI + // reads connected but no events ever arrive. The foreground-reclaim path consults this + // to tell a genuinely live link (recent data) from a stale one (force a reconnect). + DateTime _lastRx = DateTime.fromMillisecondsSinceEpoch(0); + Duration get sinceLastRx => DateTime.now().difference(_lastRx); + void _setConn(String c) { state.connection = c; onState(state); @@ -227,6 +235,7 @@ class BleEngine { _send(Cmd.linkValid, const [0x00]); }); + _lastRx = DateTime.now(); // fresh link — never treat as stale on an immediate resume _setConn('connected'); _log('Connected + subscribed.'); return true; @@ -235,6 +244,7 @@ class BleEngine { Future _subscribe(BluetoothCharacteristic c, String role) async { await c.setNotifyValue(true); _subs.add(c.onValueReceived.listen((chunk) { + _lastRx = DateTime.now(); // any notification = the link is genuinely delivering for (final frame in _asm[role]!.feed(chunk)) { if (frame.valid) _onFrame(role, frame); } @@ -327,12 +337,6 @@ class BleEngine { tsEpoch: r.tsEpoch, counter: r.counter, hr: r.hr, - spo2: r.spo2, - skinTempC: r.skinTempC, - restingHr: r.restingHr, - ax: r.accelG[0], - ay: r.accelG[1], - az: r.accelG[2], ); } } else if (recType == Record.r10) { @@ -343,12 +347,6 @@ class BleEngine { tsEpoch: r.tsEpoch, counter: r.counter, hr: r.hr, - spo2: 0, - skinTempC: 0, - restingHr: 0, - ax: 0, - ay: 0, - az: 0, ); } } diff --git a/lib/ble/hr_broadcast.dart b/lib/ble/hr_broadcast.dart new file mode 100644 index 0000000..85812bc --- /dev/null +++ b/lib/ble/hr_broadcast.dart @@ -0,0 +1,113 @@ +// hr_broadcast.dart — turn the phone into a standard BLE Heart Rate Monitor that +// re-broadcasts the band's live HR. A bike computer (Wahoo/Garmin) or gym machine +// pairs with "OpenStrap HR" exactly like a chest strap and shows the live value. +// +// The phone bridges: WHOOP --(custom GATT, read by flutter_blue_plus)--> 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/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/coach/coach_config.dart b/lib/coach/coach_config.dart new file mode 100644 index 0000000..a481110 --- /dev/null +++ b/lib/coach/coach_config.dart @@ -0,0 +1,71 @@ +// CoachConfig — local, BYOK settings for the AI coach. The API key is stored in +// the platform keychain/keystore (flutter_secure_storage); base URL + model in +// SharedPreferences. NOTHING here ever touches our backend — the key stays on the +// device and the app calls the OpenAI-compatible provider directly. + +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class CoachConfig extends ChangeNotifier { + static const _kBaseUrl = 'coach_base_url'; + static const _kModel = 'coach_model'; + static const _kKey = 'coach_api_key'; // secure storage + + static const String defaultBaseUrl = 'https://api.openai.com/v1'; + + final FlutterSecureStorage _secure = const FlutterSecureStorage(); + + String _baseUrl = defaultBaseUrl; + String _model = ''; + String? _key; // cached in-memory after load + + String get baseUrl => _baseUrl; + String get model => _model; + String? get apiKey => _key; + bool get hasKey => _key != null && _key!.isNotEmpty; + bool get configured => hasKey && _baseUrl.isNotEmpty && _model.isNotEmpty; + + /// Normalised base, no trailing slash. + String get apiBase { + var b = _baseUrl.trim(); + while (b.endsWith('/')) { + b = b.substring(0, b.length - 1); + } + return b; + } + + Future load() async { + final prefs = await SharedPreferences.getInstance(); + _baseUrl = prefs.getString(_kBaseUrl) ?? defaultBaseUrl; + _model = prefs.getString(_kModel) ?? ''; + try { + _key = await _secure.read(key: _kKey); + } catch (_) { + _key = null; + } + notifyListeners(); + } + + Future save({String? baseUrl, String? model, String? apiKey}) async { + final prefs = await SharedPreferences.getInstance(); + if (baseUrl != null) { + _baseUrl = baseUrl.trim().isEmpty ? defaultBaseUrl : baseUrl.trim(); + await prefs.setString(_kBaseUrl, _baseUrl); + } + if (model != null) { + _model = model.trim(); + await prefs.setString(_kModel, _model); + } + if (apiKey != null) { + final k = apiKey.trim(); + _key = k.isEmpty ? null : k; + if (k.isEmpty) { + await _secure.delete(key: _kKey); + } else { + await _secure.write(key: _kKey, value: k); + } + } + notifyListeners(); + } +} diff --git a/lib/coach/coach_engine.dart b/lib/coach/coach_engine.dart new file mode 100644 index 0000000..30f9228 --- /dev/null +++ b/lib/coach/coach_engine.dart @@ -0,0 +1,618 @@ +// CoachEngine — the agentic core. Talks to an OpenAI-compatible provider directly +// (BYOK), runs a tool-calling loop over read-only data tools + a plot tool + action +// tools (writes require user confirmation), and streams items back to the UI. +// +// Data flow: user asks → model calls data tools (we hit the authed backend) → model +// reasons → optionally calls plot_chart with a figure it built → optionally proposes +// an action (we confirm with the user) → model returns the final text. + +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'package:http/http.dart' as http; +import 'package:path_provider/path_provider.dart'; + +import '../net/api_client.dart'; +import 'coach_config.dart'; +import 'coach_prompt.dart'; + +// ── value types ────────────────────────────────────────────────────────────── + +/// A figure the model built from data it fetched; the app renders it animated. +class ChartSpec { + final String type; // 'bar' | 'line' | 'area' + final String title; + final List xLabels; + final List series; + final String unit; + final String? note; + ChartSpec({ + required this.type, + required this.title, + required this.xLabels, + required this.series, + this.unit = '', + this.note, + }); + + // Some OpenAI-compatible models (e.g. minimax via NVIDIA NIM) wrap array params + // as {"item":[...]} and emit numbers as strings. Be liberal in what we accept. + static List _asList(dynamic v) { + if (v is List) return v; + if (v is Map && v['item'] is List) return v['item'] as List; + if (v is Map && v['items'] is List) return v['items'] as List; + return const []; + } + + static double? _asNum(dynamic v) { + if (v == null) return null; + if (v is num) return v.toDouble(); + if (v is String) { + final d = double.tryParse(v.trim()); + if (d != null) return d; + // tolerate "62 ms", units glued on, etc. — take the first number found. + final m = RegExp(r'-?\d+(\.\d+)?').firstMatch(v); + return m == null ? null : double.tryParse(m.group(0)!); + } + if (v is Map) return _asNum(v['value'] ?? v['y'] ?? v['v']); // {value:62}/{y:62} + return null; + } + + Map toJson() => { + 'type': type, 'title': title, 'x_labels': xLabels, 'unit': unit, 'note': note, + 'series': series.map((s) => {'name': s.name, 'values': s.values}).toList(), + }; + + static ChartSpec? tryParse(Map j) { + try { + final rawSeries = _asList(j['series']); + final series = rawSeries.whereType().map((s) { + final vals = _asList(s['values'] ?? s['data'] ?? s['y']).map(_asNum).toList(); + return ChartSeries(name: (s['name'] ?? s['label'] ?? '').toString(), values: vals); + }).where((s) => s.values.any((v) => v != null)).toList(); // drop all-null series + if (series.isEmpty) return null; + final xs = _asList(j['x_labels'] ?? j['labels'] ?? j['x']).map((e) => '$e').toList(); + return ChartSpec( + type: (j['type'] ?? 'bar').toString(), + title: (j['title'] ?? '').toString(), + xLabels: xs, + series: series, + unit: (j['unit'] ?? j['y_unit'] ?? '').toString(), + note: j['note']?.toString(), + ); + } catch (_) { + return null; + } + } +} + +class ChartSeries { + final String name; + final List values; + ChartSeries({required this.name, required this.values}); +} + +/// A write the model wants to perform — surfaced to the user for confirmation. +class ActionRequest { + final String tool; + final String title; // e.g. "Log a period" + final String summary; // human description of exactly what will happen + final Map args; + ActionRequest({required this.tool, required this.title, required this.summary, required this.args}); +} + +/// One rendered chat item. +enum CoachItemKind { user, assistant, chart, error } + +class CoachItem { + final CoachItemKind kind; + final String? text; + final ChartSpec? chart; + CoachItem.user(this.text) : kind = CoachItemKind.user, chart = null; + CoachItem.assistant(this.text) : kind = CoachItemKind.assistant, chart = null; + CoachItem.error(this.text) : kind = CoachItemKind.error, chart = null; + CoachItem.chart(this.chart) : kind = CoachItemKind.chart, text = null; + + Map toJson() => {'kind': kind.name, 'text': text, 'chart': chart?.toJson()}; + + static CoachItem fromJson(Map j) { + final k = j['kind']; + if (k == 'chart' && j['chart'] is Map) { + final c = ChartSpec.tryParse((j['chart'] as Map).cast()); + if (c != null) return CoachItem.chart(c); + } + final t = j['text']?.toString(); + if (k == 'user') return CoachItem.user(t); + if (k == 'error') return CoachItem.error(t); + return CoachItem.assistant(t); + } +} + +/// Lightweight index entry for a saved chat session (for the history list). +class CoachSessionMeta { + final String id; + final String title; + final int updatedAt; // ms since epoch + final String preview; + CoachSessionMeta(this.id, this.title, this.updatedAt, this.preview); + Map toJson() => {'id': id, 'title': title, 'updatedAt': updatedAt, 'preview': preview}; + static CoachSessionMeta fromJson(Map j) => CoachSessionMeta( + (j['id'] ?? '').toString(), (j['title'] ?? '').toString(), + (j['updatedAt'] as num?)?.toInt() ?? 0, (j['preview'] ?? '').toString()); +} + +// ── engine ─────────────────────────────────────────────────────────────────── + +class CoachEngine { + final CoachConfig config; + final ApiClient api; + final String storageKey; // per-user, so accounts don't share a transcript + final http.Client _http = http.Client(); + + // OpenAI-format running history (system is added per-request) — the context we + // resend every turn so the model remembers the conversation. + final List> _history = []; + + // Serializable display transcript (text bubbles + charts) shown in the UI. + final List transcript = []; + + // Current session identity (sessions are persisted per-user, many per user). + String _sessionId = ''; + String _title = ''; + int _createdAt = 0; + String get sessionId => _sessionId; + + final Random _rand = Random(); + static const List _shenanigans = [ + 'Reading your overnight RR…', + 'Doing the Banister math…', + 'Asking your heart rate a few questions…', + 'Reverse-engineering last night…', + 'Auditing 90 days of you…', + 'Letting the data confess…', + 'Lining up the z-scores…', + 'Chasing a hunch through your HRV…', + 'Pulling the thread…', + ]; + + CoachEngine({required this.config, required this.api, this.storageKey = 'anon'}); + + void reset() { _history.clear(); transcript.clear(); } + bool get hasHistory => _history.isNotEmpty; + + Future _dir() => getApplicationDocumentsDirectory(); + Future _sessionFile(String id) async => File('${(await _dir()).path}/coach_s_${storageKey}_$id.json'); + Future _indexFile() async => File('${(await _dir()).path}/coach_idx_$storageKey.json'); + String _newId() => '${DateTime.now().millisecondsSinceEpoch}'; + + /// All saved sessions for this user, most recent first. + Future> listSessions() async { + try { + final f = await _indexFile(); + if (!await f.exists()) return []; + final list = (jsonDecode(await f.readAsString()) as List) + .map((e) => CoachSessionMeta.fromJson((e as Map).cast())).toList(); + list.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + return list; + } catch (_) { + return []; + } + } + + /// On open: resume the most recent session, or start fresh if none. + Future restore() async { + final metas = await listSessions(); + if (metas.isEmpty) { + newSession(); + return; + } + await openSession(metas.first.id); + } + + /// Start a brand-new conversation (not written to disk until first message). + void newSession() { + _sessionId = _newId(); + _title = ''; + _createdAt = 0; + _history.clear(); + transcript.clear(); + } + + /// Load a specific session into the working set. + Future openSession(String id) async { + try { + final f = await _sessionFile(id); + if (!await f.exists()) { + newSession(); + return; + } + final j = jsonDecode(await f.readAsString()) as Map; + _sessionId = id; + _title = (j['title'] ?? '').toString(); + _createdAt = (j['createdAt'] as num?)?.toInt() ?? 0; + _history + ..clear() + ..addAll(((j['history'] as List?) ?? const []).map((e) => (e as Map).cast())); + transcript + ..clear() + ..addAll(((j['transcript'] as List?) ?? const []) + .map((e) => CoachItem.fromJson((e as Map).cast()))); + } catch (_) { + newSession(); + } + } + + /// Persist the current session (caps history, updates the index). + Future persist() async { + if (transcript.isEmpty) return; + if (_sessionId.isEmpty) _sessionId = _newId(); + final now = DateTime.now().millisecondsSinceEpoch; + if (_createdAt == 0) _createdAt = now; + if (_title.isEmpty) _title = _deriveTitle(); + if (_history.length > 60) _history.removeRange(0, _history.length - 60); + try { + final f = await _sessionFile(_sessionId); + await f.writeAsString(jsonEncode({ + 'title': _title, 'createdAt': _createdAt, 'updatedAt': now, + 'history': _history, 'transcript': transcript.map((e) => e.toJson()).toList(), + })); + await _updateIndex(_sessionId, _title, now, _preview()); + } catch (_) {} + } + + Future _updateIndex(String id, String title, int updatedAt, String preview) async { + final metas = await listSessions(); + metas.removeWhere((m) => m.id == id); + metas.insert(0, CoachSessionMeta(id, title, updatedAt, preview)); + final keep = metas.take(30).toList(); + for (final d in metas.skip(30)) { + try { + final f = await _sessionFile(d.id); + if (await f.exists()) await f.delete(); + } catch (_) {} + } + try { + final f = await _indexFile(); + await f.writeAsString(jsonEncode(keep.map((m) => m.toJson()).toList())); + } catch (_) {} + } + + /// Delete a session (and start fresh if it was the current one). + Future deleteSession(String id) async { + try { + final f = await _sessionFile(id); + if (await f.exists()) await f.delete(); + } catch (_) {} + final metas = await listSessions()..removeWhere((m) => m.id == id); + try { + final f = await _indexFile(); + await f.writeAsString(jsonEncode(metas.map((m) => m.toJson()).toList())); + } catch (_) {} + if (id == _sessionId) newSession(); + } + + String _deriveTitle() { + for (final it in transcript) { + if (it.kind == CoachItemKind.user && (it.text ?? '').trim().isNotEmpty) { + final t = it.text!.trim(); + return t.length > 40 ? '${t.substring(0, 40)}…' : t; + } + } + return 'New chat'; + } + + String _preview() { + for (final it in transcript.reversed) { + final t = it.text?.trim(); + if (t != null && t.isNotEmpty) return t.length > 80 ? '${t.substring(0, 80)}…' : t; + } + return ''; + } + + /// Live model list from the provider's /models endpoint (OpenAI-compatible). + /// Static so Settings can probe an as-yet-unsaved base URL + key. + static Future> fetchModels(String apiBase, String apiKey) async { + var b = apiBase.trim(); + while (b.endsWith('/')) { + b = b.substring(0, b.length - 1); + } + final resp = await http.get( + Uri.parse('$b/models'), + headers: {'Authorization': 'Bearer $apiKey'}, + ).timeout(const Duration(seconds: 20)); + if (resp.statusCode != 200) { + throw CoachException('Models request failed (${resp.statusCode}): ${_short(resp.body)}'); + } + final j = jsonDecode(resp.body); + final data = (j['data'] as List?) ?? const []; + final ids = data.map((e) => (e as Map)['id']?.toString() ?? '').where((s) => s.isNotEmpty).toList(); + ids.sort(); + return ids; + } + + static String _short(String s) => s.length > 200 ? s.substring(0, 200) : s; + + static String _today() => DateTime.now().toUtc().toIso8601String().substring(0, 10); + + /// Run one user turn. Emits items via [onItem]; reports the current tool via + /// [onStatus]; asks the user to confirm writes via [confirm] (returns true to + /// proceed). Returns when the model produces its final answer (or hits the cap). + Future send( + String userText, { + required void Function(CoachItem) onItem, + required void Function(String?) onStatus, + required Future Function(ActionRequest) confirm, + }) async { + void emit(CoachItem it) { transcript.add(it); onItem(it); } + emit(CoachItem.user(userText)); + _history.add({'role': 'user', 'content': userText}); + + const maxIters = 10; + for (var i = 0; i < maxIters; i++) { + onStatus(_shenanigans[_rand.nextInt(_shenanigans.length)]); + final messages = >[ + {'role': 'system', 'content': '$kCoachSystemPrompt\n\nToday is ${_today()} (UTC).'}, + ..._history, + ]; + + final reply = await _chat(messages); + final toolCalls = (reply['tool_calls'] as List?) ?? const []; + final content = (reply['content'] as String?)?.trim(); + + if (toolCalls.isEmpty) { + if (content != null && content.isNotEmpty) emit(CoachItem.assistant(content)); + _history.add({'role': 'assistant', 'content': content ?? ''}); + onStatus(null); + return; + } + + // Assistant turn that requested tools (echo any interim text). + if (content != null && content.isNotEmpty) emit(CoachItem.assistant(content)); + _history.add({'role': 'assistant', 'content': content ?? '', 'tool_calls': toolCalls}); + + for (final tcRaw in toolCalls) { + final tc = tcRaw as Map; + final id = tc['id']?.toString() ?? ''; + final fn = (tc['function'] as Map?) ?? const {}; + final name = fn['name']?.toString() ?? ''; + Map args = {}; + try { + final a = fn['arguments']; + if (a is String && a.isNotEmpty) { + args = jsonDecode(a) as Map; + } else if (a is Map) { + args = a.cast(); + } + } catch (_) {} + + onStatus(_statusFor(name, args)); + final result = await _runTool(name, args, onItem: emit, confirm: confirm); + _history.add({'role': 'tool', 'tool_call_id': id, 'name': name, 'content': result}); + } + } + emit(CoachItem.assistant('I dug through several steps but couldn’t wrap that up — try narrowing the question.')); + onStatus(null); + } + + // ── provider call ──────────────────────────────────────────────────────────── + Future> _chat(List> messages) async { + final resp = await _http.post( + Uri.parse('${config.apiBase}/chat/completions'), + headers: { + 'Authorization': 'Bearer ${config.apiKey}', + 'content-type': 'application/json', + }, + body: jsonEncode({ + 'model': config.model, + 'messages': messages, + 'tools': _toolDefs, + 'tool_choice': 'auto', + 'temperature': 0.3, + }), + ).timeout(const Duration(seconds: 120)); + if (resp.statusCode != 200) { + throw CoachException('Provider error (${resp.statusCode}): ${_briefErr(resp.body)}'); + } + final j = jsonDecode(utf8.decode(resp.bodyBytes)); + final choices = (j['choices'] as List?) ?? const []; + if (choices.isEmpty) throw CoachException('Empty response from provider.'); + return (choices.first as Map)['message'] as Map; + } + + String _briefErr(String body) { + try { + final j = jsonDecode(body); + return (j['error']?['message'] ?? body).toString(); + } catch (_) { + return body.length > 300 ? body.substring(0, 300) : body; + } + } + + // ── tool execution ─────────────────────────────────────────────────────────── + Future _runTool( + String name, + Map args, { + required void Function(CoachItem) onItem, + required Future Function(ActionRequest) confirm, + }) async { + try { + switch (name) { + // reads + case 'get_today': + return _enc(await api.getToday()); + case 'get_trend': + return _enc(await api.getTrend('${args['metric']}', scale: '${args['scale'] ?? 'week'}')); + case 'get_day': + return _enc(await _day('${args['kind']}', '${args['date']}')); + case 'get_sleep_history': + return _enc(await api.getSleep()); + case 'get_strain_history': + return _enc(await api.getStrain()); + case 'get_sessions': + return _enc(await api.getSessions()); + case 'get_workouts': + return _enc(await api.getWorkouts(range: '${args['range'] ?? 'month'}')); + case 'get_cycle': + return _enc(await api.getCycle()); + case 'get_journal': + return _enc(await api.getJournal(range: '${args['range'] ?? '30d'}')); + case 'get_journal_insights': + return _enc(await api.getJournalInsights(range: '${args['range'] ?? '90d'}')); + case 'get_profile': + return _enc(await api.getProfile()); + case 'get_records': + return _enc(await api.getRecords()); + case 'get_history': + return _enc(await api.getHistory(range: '${args['range'] ?? '30d'}')); + + // plot + case 'plot_chart': + final spec = ChartSpec.tryParse(args); + if (spec == null) return 'Could not parse figure; check the schema.'; + onItem(CoachItem.chart(spec)); + return 'Chart rendered for the user.'; + + // actions (confirmed) + case 'log_journal': + return await _action(confirm, ActionRequest( + tool: name, title: 'Log journal', + summary: 'Add journal for ${args['date']}: tags ${args['tags'] ?? []}, note "${args['note'] ?? ''}".', + args: args, + ), () async { + await api.postJournal('${args['date']}', + ((args['tags'] as List?) ?? const []).map((e) => '$e').toList(), '${args['note'] ?? ''}'); + return 'Journal saved.'; + }); + case 'log_period': + return await _action(confirm, ActionRequest( + tool: name, title: 'Log period', + summary: 'Log a period start on ${args['date']}.', args: args, + ), () async { await api.postCycleLog('${args['date']}', kind: 'start'); return 'Period logged.'; }); + case 'start_workout': + return await _action(confirm, ActionRequest( + tool: name, title: 'Start workout', + summary: 'Start a ${args['type'] ?? 'workout'} session now.', args: args, + ), () async { final r = await api.startWorkout('${args['type'] ?? 'other'}'); return _enc(r); }); + case 'end_workout': + return await _action(confirm, ActionRequest( + tool: name, title: 'End workout', + summary: 'End the active workout.', args: args, + ), () async { final r = await api.endWorkout('${args['workout_id']}'); return _enc(r); }); + case 'set_step_goal': + return await _action(confirm, ActionRequest( + tool: name, title: 'Set step goal', + summary: 'Set your daily step goal to ${args['goal']}.', args: args, + ), () async { await api.setStepGoal((args['goal'] as num).toInt()); return 'Step goal updated.'; }); + + default: + return 'Unknown tool: $name'; + } + } catch (e) { + return 'Tool $name failed: ${e is ApiException ? e.body : e}'; + } + } + + Future _action( + Future Function(ActionRequest) confirm, + ActionRequest req, + Future Function() run, + ) async { + final ok = await confirm(req); + if (!ok) return 'User declined the action. Do not retry it.'; + return await run(); + } + + Future> _day(String kind, String date) { + switch (kind) { + case 'sleep': return api.getDaySleep(date); + case 'strain': return api.getDayStrain(date); + case 'stress': return api.getDayStress(date); + case 'wear': return api.getDayWear(date); + case 'timeline': return api.getDayTimeline(date); + case 'lungs': return api.getDayLungs(date); + case 'heart': + default: return api.getDayHeart(date); + } + } + + String _enc(Object? data) { + final s = jsonEncode(data); + return s.length > 16000 ? '${s.substring(0, 16000)}…(truncated)' : s; + } + + String _statusFor(String name, Map args) { + switch (name) { + case 'get_today': return 'Reading today…'; + case 'get_trend': return 'Pulling ${args['metric']} trend…'; + case 'get_day': return 'Reading ${args['kind']} for ${args['date']}…'; + case 'get_sleep_history': return 'Reading sleep history…'; + case 'get_strain_history': return 'Reading strain history…'; + case 'get_sessions': return 'Reading workouts…'; + case 'get_workouts': return 'Reading workouts…'; + case 'get_cycle': return 'Reading cycle…'; + case 'get_journal': case 'get_journal_insights': return 'Reading journal…'; + case 'get_records': return 'Checking your records…'; + case 'plot_chart': return 'Plotting…'; + default: return 'Working…'; + } + } + + void dispose() => _http.close(); + + // ── tool schema (OpenAI format) ─────────────────────────────────────────────── + static Map _fn(String name, String desc, Map props, [List required = const []]) => { + 'type': 'function', + 'function': { + 'name': name, + 'description': desc, + 'parameters': {'type': 'object', 'properties': props, 'required': required}, + }, + }; + + static final List> _toolDefs = [ + _fn('get_today', 'Today\'s snapshot: recovery, strain, sleep, resting HR, steps, readiness, skin-temp/SpO₂ deviations.', {}), + _fn('get_trend', 'A metric over time (server-aggregated buckets). Use for "trend/last week/month".', { + 'metric': {'type': 'string', 'description': 'one of: strain, recovery, resting_hr, hrv, sdnn, lf_hf, hrv_cv, calories, steps, wear, readiness, vo2max, fitness, fatigue, form, monotony, acwr, dip, efficiency, deep, rem, light, regularity, resp, sleep, stress, skin_temp, spo2'}, + 'scale': {'type': 'string', 'enum': ['week', 'month', 'quarter']}, + }, ['metric']), + _fn('get_day', 'Detailed data for one day & domain.', { + 'kind': {'type': 'string', 'enum': ['heart', 'sleep', 'strain', 'stress', 'wear', 'timeline', 'lungs']}, + 'date': {'type': 'string', 'description': 'YYYY-MM-DD'}, + }, ['kind', 'date']), + _fn('get_sleep_history', 'Recent nightly sleep rows.', {}), + _fn('get_strain_history', 'Recent daily strain rows.', {}), + _fn('get_sessions', 'Recent detected/auto workouts.', {}), + _fn('get_workouts', 'Workouts over a range.', {'range': {'type': 'string', 'enum': ['week', 'month', 'quarter']}}), + _fn('get_cycle', 'Menstrual cycle status + prediction (if the user enabled tracking).', {}), + _fn('get_journal', 'Behavior-tag journal entries.', {'range': {'type': 'string'}}), + _fn('get_journal_insights', 'How tags correlate with the user\'s own metrics.', {'range': {'type': 'string'}}), + _fn('get_profile', 'Profile: age, sex, height, weight, step goal.', {}), + _fn('get_records', 'Personal records & streaks.', {}), + _fn('get_history', 'Compact multi-metric history.', {'range': {'type': 'string'}}), + _fn('plot_chart', 'Render a chart for the user from data you fetched. Build the figure yourself.', { + 'type': {'type': 'string', 'enum': ['bar', 'line', 'area']}, + 'title': {'type': 'string'}, + 'x_labels': {'type': 'array', 'items': {'type': 'string'}}, + 'series': {'type': 'array', 'items': {'type': 'object', 'properties': { + 'name': {'type': 'string'}, + 'values': {'type': 'array', 'items': {'type': ['number', 'null']}}, + }}}, + 'unit': {'type': 'string'}, + 'note': {'type': 'string'}, + }, ['type', 'x_labels', 'series']), + _fn('log_journal', 'Log a journal entry (asks the user to confirm).', { + 'date': {'type': 'string'}, 'tags': {'type': 'array', 'items': {'type': 'string'}}, 'note': {'type': 'string'}, + }, ['date']), + _fn('log_period', 'Log a period start (asks the user to confirm).', {'date': {'type': 'string'}}, ['date']), + _fn('start_workout', 'Start a live workout (asks the user to confirm).', {'type': {'type': 'string'}}), + _fn('end_workout', 'End the active workout (asks the user to confirm).', {'workout_id': {'type': 'string'}}, ['workout_id']), + _fn('set_step_goal', 'Set the daily step goal (asks the user to confirm).', {'goal': {'type': 'integer'}}, ['goal']), + ]; +} + +class CoachException implements Exception { + final String message; + CoachException(this.message); + @override + String toString() => message; +} diff --git a/lib/coach/coach_prompt.dart b/lib/coach/coach_prompt.dart new file mode 100644 index 0000000..d03f3fb --- /dev/null +++ b/lib/coach/coach_prompt.dart @@ -0,0 +1,76 @@ +// The coach's system prompt — domain knowledge + STRICT tool + output contract. +// The coach reasons ONLY over data it fetches with tools; it never invents numbers. + +const String kCoachSystemPrompt = ''' +You are the OpenStrap AI Coach — an expert in wearable physiology, training load, +sleep science, and HRV, embedded in the user's own OpenStrap app (an open-source +WHOOP-4.0 alternative). You can read EVERY metric in the user's account via tools +and render charts the app draws natively. + +# SCOPE — stay strictly on-topic (most important rule) +ONLY answer questions about the user's health and fitness: recovery, sleep, HRV, +heart rate, strain/training, workouts, steps, body metrics, menstrual cycle, +recovery-focused nutrition/hydration/caffeine, illness signals, and how to read +their own OpenStrap data and app features. + +REFUSE everything else — coding/programming, math homework, general knowledge, +trivia, writing essays/emails, current events, anything unrelated to the user's +health. Do NOT write code under any circumstances. When a request is off-topic, +decline in ONE friendly sentence and steer back, e.g.: "I'm your health & fitness +coach, so I'll stick to that — ask me about your recovery, sleep, training, or any +metric in your data." Do not partially comply (no code snippets, no exceptions). + +# How OpenStrap measures things (interpret correctly) +- Resting HR: 5th-percentile sleeping HR. Lower trend = fitter/recovered. +- Strain: Banister TRIMP, log-squashed to 0–21 (like WHOOP). Daily load. +- HRV: RMSSD/SDNN/pNN50 from real RR intervals. Higher RMSSD = more recovered. +- Recovery: Plews ln(RMSSD) z-score vs the user's baseline → 0–100. Needs ~5 nights. +- Sleep: Cole-Kripke + HR-dip; stages are BETA (wrist ≠ EEG — never claim clinical accuracy). +- Training load: EWMA ACWR; 0.8–1.3 = sweet spot, >1.5 = spike risk. +- Illness watch: Mahalanobis of {resting HR↑, RMSSD↓, skin-temp↑}. +- Skin temp & SpO₂ are RELATIVE indices (Δ vs personal baseline), NOT clinical °C / %. +- Steps: AN-2554 estimate. Cycle: log-anchored calendar method (only if user enabled it). + +# TOOLS — you MUST fetch before you answer. Never state a number you didn't fetch. +Read tools (call freely, in parallel when useful): +- get_today() — today's recovery, strain, sleep, resting HR, steps, readiness, skin-temp/SpO₂ Δ. +- get_trend(metric, scale) — server-bucketed history. scale ∈ week|month|quarter. + metric ∈ strain, recovery, resting_hr, hrv, sdnn, lf_hf, hrv_cv, calories, steps, wear, + readiness, vo2max, fitness, fatigue, form, monotony, acwr, dip, efficiency, deep, rem, + light, regularity, resp, sleep, stress, skin_temp, spo2. +- get_day(kind, date) — one day, kind ∈ heart|sleep|strain|stress|wear|timeline|lungs; date=YYYY-MM-DD. +- get_sleep_history(), get_strain_history(), get_sessions(), get_workouts(range). +- get_cycle() — menstrual cycle (returns enabled:false if the user hasn't opted in; respect that). +- get_journal(range), get_journal_insights(range) — behavior tags & their correlations. +- get_profile(), get_records() (PRs/streaks), get_history(range). + +Plot tool — call to SHOW data you fetched (the app animates it): +- plot_chart(type, title, x_labels, series, unit, note) + type ∈ bar|line|area. x_labels: string[]. series: [{name, values:number[]}]. + values MUST be PLAIN NUMBERS (e.g. 62, not "62 ms"), one per x_label, in the same order; + use null only for a genuine gap. For trends/comparisons plot the FULL series of points + (one per day/bucket) — never collapse a series to a single averaged point. Use line/area for + time-series, bar for one value per category. Build the figure from fetched data (you may + combine series, e.g. RMSSD vs resting HR). + +Action tools — the app ASKS THE USER TO CONFIRM before any write. Only when clearly requested: +- log_journal(date, tags, note), log_period(date), start_workout(type), end_workout(workout_id), + set_step_goal(goal). + +# OUTPUT FORMAT (strict — the app renders Markdown) +- Be CONCISE. Lead with the direct answer in 1–2 sentences, then brief support. +- For ANY multi-day / time-series / comparison, call plot_chart INSTEAD of a big table. + Use a small Markdown table only for ≤4 rows of non-time data. +- Bold the key numbers. Cite the metric and day/period ("**62 ms** last night vs your **71 ms** baseline"). +- Keep structure light: at most ONE short "##" heading; do NOT use horizontal rules (---) or + stacks of headings. Bullets ("- ") are fine. +- Emoji: use real Unicode sparingly (✅ ⚠️). NEVER colon shortcodes like ":warning:". +- End with ONE line "Not medical advice." ONLY when you gave health guidance — not every message. + +# Honesty (non-negotiable) +Never fabricate. Missing data → say so and show "—". Respect honest scope (sleep stages = beta; +skin-temp/SpO₂ relative; HRV needs enough nights). You are not a doctor. + +# Style +Warm, sharp, evidence-based. Answer → why → (optional) chart → one concrete suggestion. +'''; diff --git a/lib/data/db.dart b/lib/data/db.dart index a144e4b..4016928 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -26,17 +26,7 @@ class LocalDb { path, onCreate: (db, version) async { await _createRaw(db); - await db.execute(''' - CREATE TABLE samples ( - counter INTEGER PRIMARY KEY, - ts INTEGER NOT NULL, - hr INTEGER, - spo2 INTEGER, - skin_temp_c REAL, - resting_hr INTEGER, - ax REAL, ay REAL, az REAL - ) - '''); + await _createSamples(db); await db.execute('CREATE INDEX idx_samples_ts ON samples(ts)'); await _createEvents(db); }, @@ -50,11 +40,31 @@ class LocalDb { await db.execute('DROP TABLE IF EXISTS raw_records'); await _createRaw(db); } + if (oldV < 4) { + // The old samples table cached decoded sensor fields (spo2/skin_temp) that + // (a) were read from MISIDENTIFIED offsets and (b) nothing ever read. The + // edge no longer decodes sensors — drop + recreate as a header-only index. + await db.execute('DROP TABLE IF EXISTS samples'); + await _createSamples(db); + await db.execute('CREATE INDEX IF NOT EXISTS idx_samples_ts ON samples(ts)'); + } }, - version: 3, + version: 4, ); } + // samples — header-only record index (counter, ts, hr). The band is a raw pipe; + // sensors are decoded in the cloud from the uploaded raw hex, never on-device. + static Future _createSamples(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS samples ( + counter INTEGER PRIMARY KEY, + ts INTEGER NOT NULL, + hr INTEGER + ) + '''); + } + // raw_records — keyed by the full frame hex (unique; dedupes identical // historical re-drains AND coexists with counter-less live packets). static Future _createRaw(Database db) async { diff --git a/lib/data/models.dart b/lib/data/models.dart index 474841c..5de190a 100644 --- a/lib/data/models.dart +++ b/lib/data/models.dart @@ -1,26 +1,17 @@ // Data models shared across the app. -/// A decoded 1 Hz telemetry sample (from a type-24 record). Mirrors the backend -/// `samples` columns and parse_r24 output. +/// The HEADER of a 1 Hz record (type-24 / R10): timestamp + counter + HR. The +/// sensor block is NOT decoded on-device — the band is a raw pipe, the full frame +/// is uploaded as hex and the cloud owns the sensor decode. class Sample { final int tsEpoch; final int counter; final int hr; // 0 = off-wrist (never display as a heart rate) - final int spo2; - final double skinTempC; - final int restingHr; - final double ax, ay, az; Sample({ required this.tsEpoch, required this.counter, required this.hr, - required this.spo2, - required this.skinTempC, - required this.restingHr, - required this.ax, - required this.ay, - required this.az, }); bool get wristOn => hr > 0; @@ -29,36 +20,12 @@ class Sample { 'ts': tsEpoch, 'counter': counter, 'hr': hr, - 'spo2': spo2, - 'skin_temp_c': skinTempC, - 'resting_hr': restingHr, - 'ax': ax, - 'ay': ay, - 'az': az, }; factory Sample.fromDbMap(Map m) => Sample( tsEpoch: m['ts'] as int, counter: m['counter'] as int, hr: m['hr'] as int, - spo2: m['spo2'] as int, - skinTempC: (m['skin_temp_c'] as num).toDouble(), - restingHr: m['resting_hr'] as int, - ax: (m['ax'] as num).toDouble(), - ay: (m['ay'] as num).toDouble(), - az: (m['az'] as num).toDouble(), - ); - - factory Sample.fromBackendJson(Map m) => Sample( - tsEpoch: m['ts'] as int, - counter: m['counter'] as int, - hr: (m['hr'] ?? 0) as int, - spo2: (m['spo2'] ?? 0) as int, - skinTempC: ((m['skin_temp_c'] ?? 0) as num).toDouble(), - restingHr: (m['resting_hr'] ?? 0) as int, - ax: ((m['ax'] ?? 0) as num).toDouble(), - ay: ((m['ay'] ?? 0) as num).toDouble(), - az: ((m['az'] ?? 0) as num).toDouble(), ); } 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/main.dart b/lib/main.dart index 9fd6941..8ff035b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,25 +1,89 @@ import 'package:flutter/material.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:provider/provider.dart'; import 'app.dart'; import 'ble/ios_ble_restore.dart'; +import 'notify/notification_service.dart'; +import 'coach/coach_config.dart'; import 'state/app_state.dart'; -import 'sync/background_sync.dart'; +import 'state/units_controller.dart'; +import 'theme/theme_controller.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(); - await WidgetService.init(); + + // iOS CoreBluetooth State Preservation & Restoration: opt the flutter_blue_plus + // central (the one that actually subscribes to the band's HR/event characteristics) + // into a restore identifier. Apple preserves a connection AND its characteristic + // subscriptions ONLY for a central created with a restore id — without this, a + // suspended app resumes with the peripheral still flagged "connected" but its GATT + // notifications dead until a full reconnect+re-subscribe (the "connected, no events" + // bug). MUST be set before any other FBP call. No-op on Android. + try { + await FlutterBluePlus.setOptions(restoreState: true); + } catch (_) {/* older plugin / unsupported platform — ignore */} + + // 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). + // 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, + ); + } + + // Local display-units preference (metric/imperial). Best-effort; defaults to metric. + UnitsController units; + try { + units = await UnitsController.bootstrap(); + } catch (e, st) { + debugPrint('[main] UnitsController.bootstrap failed, using metric: $e\n$st'); + units = UnitsController.seed(UnitSystem.metric); + } + + // Local BYOK AI-coach config (key in keychain). Best-effort load. + final coachConfig = CoachConfig(); + try { + await coachConfig.load(); + } catch (e, st) { + debugPrint('[main] CoachConfig.load failed: $e\n$st'); + } + runApp( - ChangeNotifierProvider( - create: (_) => AppState(), + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => AppState()), + ChangeNotifierProvider.value(value: theme), + ChangeNotifierProvider.value(value: units), + ChangeNotifierProvider.value(value: coachConfig), + ], child: const OpenStrapApp(), ), ); } + +/// 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/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/models/payloads.dart b/lib/models/payloads.dart index ebf4b19..cf7455e 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,18 +55,21 @@ 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 /// 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(), ); } @@ -87,7 +95,14 @@ class TodayData { /// Respiratory rate (PPG) — only present once validated server-side; else null. RespData? get resp => _resp == null ? null : RespData(_resp); + // 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'); @@ -323,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/net/api_client.dart b/lib/net/api_client.dart index 0980c39..8dd28c4 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}); @@ -155,17 +164,6 @@ class ApiClient { return u; } - // ── query (insights/history) ───────────────────────────────────────────────── - Future>> fetchMetrics(int fromTs, int toTs) => - _getList('/metrics', {'from': '$fromTs', 'to': '$toTs'}); - Future>> fetchSleep() => _getList('/sleep'); - Future>> fetchDaily() => _getList('/strain'); // daily table - Future> fetchTrends() async { - final resp = await _authed((h) => _client.get(_u('/trends'), headers: h)); - if (resp.statusCode != 200) throw ApiException(resp.statusCode, resp.body); - return _decode(resp.body); - } - // ── production insights endpoints (UI screens) ─────────────────────────────── // All JWT-authed via _authed. Responses parsed defensively by the model layer. @@ -176,6 +174,15 @@ class ApiClient { Future>> getSleep({int? from, int? to}) => _getList('/sleep', _range(from, 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)); @@ -184,10 +191,6 @@ class ApiClient { Future>> getSessions({int? from, int? to}) => _getList('/sessions', _range(from, to)); - /// GET /trends?days=90 → object of named series + baseline + anomaly. - Future> getTrends({int days = 90}) => - _getObj('/trends', {'days': '$days'}); - /// GET /history?range=7d|30d|90d|365d → per-metric series + summaries /// (avg/min/max/total/delta-vs-prior-period/trend) + calendar + zone totals. Future> getHistory({String range = '30d'}) => @@ -209,6 +212,31 @@ class ApiClient { Future> getJournalInsights({String range = '90d'}) => _getObj('/journal/insights', {'range': range}); + /// POST /spotcheck — decode collected live RR frames → HRV (RMSSD/SDNN/pNN50/HR). + Future> spotCheck(List records) async { + final resp = await _authed((h) => _client.post(_u('/spotcheck'), + headers: h, body: jsonEncode({'records': records}))); + if (resp.statusCode != 200) throw ApiException(resp.statusCode, resp.body); + return _decode(resp.body); + } + + // ── menstrual cycle ──────────────────────────────────────────────────────── + /// GET /cycle → phase + prediction + logs + biometric overlay. + Future> getCycle() => _getObj('/cycle'); + + /// POST /cycle/log — log a period event (kind: start|end|spotting). + Future postCycleLog(String date, {String kind = 'start', String? note}) async { + final resp = await _authed((h) => _client.post(_u('/cycle/log'), + headers: h, body: jsonEncode({'date': date, 'kind': kind, if (note != null) 'note': note}))); + if (resp.statusCode != 200) throw ApiException(resp.statusCode, resp.body); + } + + /// DELETE /cycle/log?date= — remove a logged event. + Future deleteCycleLog(String date) async { + final resp = await _authed((h) => _client.delete(_u('/cycle/log', {'date': date}), headers: h)); + if (resp.statusCode != 200) throw ApiException(resp.statusCode, resp.body); + } + // ── day drill-down detail (all computed server-side) ─────────────────────── /// GET /day/strain?date= → cumulative strain curve + zones + HR stats + sessions. Future> getDayStrain(String date) => @@ -222,10 +250,78 @@ 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}); + + /// 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'}) => + _getObj('/workouts', {'range': range}); + + /// 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'), + 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); + } + + /// POST /workout/:id/type {type} → confirm/correct an auto-detected workout's type. + /// Feeds the classifier calibration ledger. Returns {type, type_source}. + Future> setWorkoutType(String id, String type) async { + final resp = await _authed((h) => _client.post(_u('/workout/$id/type'), + headers: h, body: jsonEncode({'type': type}))); + if (resp.statusCode != 200) throw ApiException(resp.statusCode, resp.body); + return _decode(resp.body); + } + + /// GET /day/hrv?date= → daytime (waking) HRV ultradian timeline. + Future> getDayHrv(String date) => + _getObj('/day/hrv', {'date': date}); + + /// 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'); @@ -250,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/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_relay.dart b/lib/notify/notification_relay.dart new file mode 100644 index 0000000..5de52fe --- /dev/null +++ b/lib/notify/notification_relay.dart @@ -0,0 +1,217 @@ +// notification_relay.dart — relay selected phone-app notifications to the strap as +// a haptic buzz. ANDROID ONLY: it rides Android's NotificationListenerService (the +// `notification_listener_service` plugin). iOS has no API to observe other apps' +// notifications, so on iOS this whole feature is inert and the UI never shows it. +// +// Flow: user grants "Notification access" + picks apps → we subscribe to the system +// notification stream → for each NEW notification whose package is on the allow-list, +// we buzz the band (Cmd.runHapticsPattern, via the injected callback). Same persisted +// ChangeNotifier idiom as GestureSettings/ThemeController so the settings UI is live. + +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:flutter/services.dart' show MethodChannel; +import 'package:flutter/widgets.dart'; +import 'package:notification_listener_service/notification_event.dart'; +import 'package:notification_listener_service/notification_listener_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class NotificationRelay extends ChangeNotifier with WidgetsBindingObserver { + NotificationRelay({required this.buzz, required this.isConnected}); + + // The plugin's own MethodChannel. v1.0.0's Dart API doesn't expose the native + // rebind/health handlers, so we invoke them directly to self-heal when Android + // unbinds the NotificationListenerService (it does this routinely over time). + static const MethodChannel _pluginChannel = + MethodChannel('x-slayer/notifications_channel'); + static const Duration _healEvery = Duration(seconds: 120); + Timer? _healTimer; + + /// Fire the strap haptic. Wired by AppState to `engine.buzz()`. Best-effort. + final Future Function() buzz; + + /// Whether the band is currently connected (no point buzzing nothing). + final bool Function() isConnected; + + static const _kEnabled = 'notif_relay_enabled'; + static const _kPackages = 'notif_relay_packages'; + + /// Only Android can observe other apps' notifications. Everything below is a + /// no-op when this is false, and the UI hides the feature entirely. + bool get supported => Platform.isAndroid; + + bool _enabled = false; + bool get enabled => _enabled; + + bool _granted = false; + bool get permissionGranted => _granted; + + final Set _packages = {}; + Set get packages => _packages; + bool isAppEnabled(String pkg) => _packages.contains(pkg); + int get appCount => _packages.length; + + /// True only when everything needed to actually buzz is in place. + bool get active => supported && _enabled && _granted && _packages.isNotEmpty; + + StreamSubscription? _sub; + // Per-package de-dupe: ignore repeat posts of the same app within this window + // (apps re-post the same notification as it updates), plus a global floor so a + // burst never machine-guns the strap. + final Map _lastBuzzMs = {}; + int _lastAnyBuzzMs = 0; + static const _perAppCooldownMs = 4000; + static const _globalFloorMs = 800; + + /// Load saved state, refresh permission, and start listening if active. Call + /// once at startup. No-op on iOS. + Future bootstrap() async { + if (!supported) return; + final prefs = await SharedPreferences.getInstance(); + _enabled = prefs.getBool(_kEnabled) ?? false; + _packages + ..clear() + ..addAll(prefs.getStringList(_kPackages) ?? const []); + WidgetsBinding.instance.addObserver(this); + await refreshPermission(); + _resync(); + notifyListeners(); + } + + // The OS can unbind the listener and kill our stream while we're backgrounded. + // On every foreground return, re-check the grant and force the listener back. + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed && supported) { + refreshPermission().then((_) { + _resync(); + _heal(); + }); + } + } + + /// Re-query the OS "Notification access" grant (it can change while we're + /// backgrounded — user revokes it in Settings). Returns the current value. + Future refreshPermission() async { + if (!supported) return false; + try { + _granted = await NotificationListenerService.isPermissionGranted(); + } catch (_) { + _granted = false; + } + notifyListeners(); + return _granted; + } + + /// Open the system Notification-access settings page and return once the user + /// comes back. We re-read the real grant rather than trusting the return value. + Future requestPermission() async { + if (!supported) return false; + try { + await NotificationListenerService.requestPermission(); + } catch (_) {/* user may just back out */} + final ok = await refreshPermission(); + _resync(); + return ok; + } + + Future setEnabled(bool on) async { + if (!supported || on == _enabled) return; + _enabled = on; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kEnabled, on); + _resync(); + notifyListeners(); + } + + Future setAppEnabled(String pkg, bool on) async { + if (!supported) return; + if (on) { + if (!_packages.add(pkg)) return; + } else { + if (!_packages.remove(pkg)) return; + } + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList(_kPackages, _packages.toList()); + _resync(); + notifyListeners(); + } + + // Subscribe only when the feature can actually do something; otherwise tear the + // stream down so we're not holding a system callback for nothing. Also runs a + // periodic heal so a system-unbound listener gets re-armed while we're alive. + void _resync() { + final shouldListen = supported && _enabled && _granted; + if (shouldListen) { + _startListening(); + _healTimer ??= Timer.periodic(_healEvery, (_) => _heal()); + } else { + _sub?.cancel(); + _sub = null; + _healTimer?.cancel(); + _healTimer = null; + } + } + + void _startListening() { + if (_sub != null) return; + try { + _sub = NotificationListenerService.notificationsStream.listen( + _onNotification, + // If the stream errors or closes, drop it and let the next _resync/heal + // re-arm — a dead subscription must never silently stay dead. + onError: (_) { + _sub?.cancel(); + _sub = null; + }, + onDone: () { + _sub = null; + }, + cancelOnError: true, + ); + } catch (_) {/* stream unavailable — stay inert */} + } + + // Ask the native side whether the listener is still bound; if not, force a + // rebind + reconnect via the plugin's (Dart-unexposed) handlers. All best-effort + // — older plugin builds or pre-API-24 devices simply no-op. + Future _heal() async { + if (!active) return; + _startListening(); // re-arm the Dart stream if it died + try { + final connected = + await _pluginChannel.invokeMethod('isServiceConnected') ?? true; + if (!connected) { + try { await _pluginChannel.invokeMethod('forceRequestRebind'); } catch (_) {} + try { await _pluginChannel.invokeMethod('reconnectService'); } catch (_) {} + } + } catch (_) {/* handler absent on this plugin build — ignore */} + } + + void _onNotification(ServiceNotificationEvent e) { + // Only fresh, user-facing posts: skip removals and persistent/ongoing ones + // (media players, foreground-service notifications) — those aren't "a ping". + if (e.hasRemoved || e.onGoing) return; + final pkg = e.packageName; + if (pkg.isEmpty || !_packages.contains(pkg)) return; + if (!isConnected()) return; + + final now = DateTime.now().millisecondsSinceEpoch; + final lastForPkg = _lastBuzzMs[pkg] ?? 0; + if (now - lastForPkg < _perAppCooldownMs) return; + if (now - _lastAnyBuzzMs < _globalFloorMs) return; + _lastBuzzMs[pkg] = now; + _lastAnyBuzzMs = now; + // Fire-and-forget; never let a BLE hiccup throw into the system callback. + unawaited(buzz().catchError((_) {})); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _healTimer?.cancel(); + _sub?.cancel(); + super.dispose(); + } +} diff --git a/lib/notify/notification_service.dart b/lib/notify/notification_service.dart new file mode 100644 index 0000000..691428a --- /dev/null +++ b/lib/notify/notification_service.dart @@ -0,0 +1,133 @@ +// 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; + // 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/launcher_icon'); + // 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/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..f0296ce 100644 --- a/lib/protocol/records.dart +++ b/lib/protocol/records.dart @@ -47,32 +47,28 @@ Uint8List hexToBytes(String hex) { } // ── 5.5 The type-24 record (the historical substrate), 1 Hz ────────────────── +// EDGE DECODES HEADER ONLY. The band is a raw sensor pipe: the full frame is +// stored + uploaded as hex (raw_records) and the CLOUD owns the sensor decode +// (openstrap-protocol/ts/records.ts → spo2/temp/ppg/ambient/HRV). We extract just +// the header here for sync bookkeeping (timestamp span, idempotency counter, HR). +// Do NOT re-add the sensor block on-device — it's never read and the cloud is the +// system of record. class R24 { final int tsEpoch; // u32 @[7:11] seconds — AUTHORITATIVE final int tsSubsec; // u16 @[11:13] — AUTHORITATIVE final int counter; // u32 @[3:7] — AUTHORITATIVE (idempotency key) final int hr; // u8 @[17] bpm — AUTHORITATIVE; 0 = no reading (off-wrist) - final int spo2; // u8 @[72] % — EMPIRICAL - final double skinTempC; // [70]/4 °C — EMPIRICAL - final int restingHr; // u8 @[88] — EMPIRICAL (held baseline) - final List accelG; // float32 ×3 @[36:48] g — EMPIRICAL - final String rawTail; // [13:] hex — app-opaque (relayed raw to cloud) R24({ required this.tsEpoch, required this.tsSubsec, required this.counter, required this.hr, - required this.spo2, - required this.skinTempC, - required this.restingHr, - required this.accelG, - required this.rawTail, }); } -/// Decode a type-24 record. `inner` starts at the packet_type byte (offset 0). -/// Returns null if too short (guard `< 89`). +/// Decode the HEADER of a type-24 record (`inner` starts at the packet_type byte). +/// Returns null if too short. Sensors live in the raw hex; the cloud decodes them. R24? parseR24(Uint8List inner) { if (inner.length < 89) return null; return R24( @@ -80,15 +76,6 @@ R24? parseR24(Uint8List inner) { tsSubsec: u16(inner, 11), counter: u32(inner, 3), hr: inner[17], - spo2: inner[72], - skinTempC: _round(inner[70] / 4.0, 2), - restingHr: inner[88], - accelG: [ - _round(f32(inner, 36), 4), - _round(f32(inner, 40), 4), - _round(f32(inner, 44), 4), - ], - rawTail: _hex(Uint8List.sublistView(inner, 13)), ); } @@ -231,6 +218,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); } @@ -365,7 +357,8 @@ Decoded _decodeDataRecord(Uint8List inner) { return Decoded('realtime_hr', {'rec_type': recType, 'hr': r.hr, 'wearing': true}); } } - // Big record by type byte. We only field-decode R24 (the substrate). + // R24: header only on-device (ts/counter/hr). The full frame is uploaded as raw + // hex and the cloud decodes the sensor block. if (recType == Record.r24) { final r = parseR24(inner); if (r != null) { @@ -374,11 +367,6 @@ Decoded _decodeDataRecord(Uint8List inner) { 'ts_subsec': r.tsSubsec, 'counter': r.counter, 'hr': r.hr, - 'spo2': r.spo2, - 'skin_temp_c': r.skinTempC, - 'resting_hr': r.restingHr, - 'accel_g': r.accelG, - 'raw_tail': r.rawTail, }); } } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 72a8d4a..eb5ffe8 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -8,23 +8,36 @@ // else → main Shell (auto-connect saved band, drain, live, upload) 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'; +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'; +import '../gestures/gesture_dispatcher.dart'; import '../data/models.dart'; import '../net/api_client.dart'; import '../live/live_activity.dart'; -import '../sync/background_sync.dart'; +import '../notify/device_alerts.dart'; +import '../notify/notification_relay.dart'; +import '../notify/notification_service.dart'; import '../sync/config.dart'; +import '../sync/edge_tracking.dart'; 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; @@ -33,6 +46,18 @@ class AppState extends ChangeNotifier { PairedDevice? paired; 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; + + /// Relay selected phone-app notifications to the strap as a buzz (Android only). + /// Exposed for the settings UI; buzzes via the live BLE engine when connected. + late final NotificationRelay notificationRelay = NotificationRelay( + buzz: () => engine.buzz(), + isConnected: () => engine.isConnected, + ); Sample? lastSynced; Map dbCounts = {'raw': 0, 'pending': 0}; final List logLines = []; @@ -42,8 +67,42 @@ 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 + /// this state (see [pauseForBackground]) so the OS keeps resuming us per BLE + /// notification and the live drain + flush continue. + bool _background = false; + + // THE single upload path. Every record source (live 0x28/R10/IMU, historical drain) + // only ever STORES locally (raw_records, hex PK, retain-until-200). This one timer is + // the ONLY thing that POSTs: it kicks once on start, then uploads everything pending + // every _kFlushInterval, deleting each chunk only on its confirmed 200 (so a non-200/ + // 429 just leaves rows queued for the next tick — no backoff needed; the 60s cadence + // sits well under the backend rate limit of burst 30 / refill 0.5/s). Uniform + // behaviour everywhere — no per-event upload() calls to drift out of sync. + // + // The 60s cadence collapses the steady-state 1 Hz trickle from ~1 POST/15s (~5,760/ + // day) to ~1 POST/min (~1,440/day) — ~4× fewer R2 puts + D1 writes + Workers requests, + // with zero data loss (cloud metrics land at the wake-close, so ≤1 min upload latency + // is invisible). Connection-independent: keeps flushing through background and across + // reconnects; stopped only on session teardown (logout / signOut / unpair / endSession). + Timer? _flushTimer; + static const Duration _kFlushInterval = Duration(seconds: 60); + // Backfill fast-drain: while a large local backlog remains (e.g. the morning drain of a + // full night the band buffered while disconnected), re-upload after this short delay + // instead of waiting the full _kFlushInterval, so thousands of records clear in minutes + // rather than over many 60 s ticks. The delay lets a little more BLE backlog accumulate + // so each pass sends a full _kBackfillBatch. Self-limiting — it stops re-arming once + // pending drops below kBacklogThreshold, and the steady 60 s timer resumes as baseline. + static const Duration _kBackfillFlushDelay = Duration(seconds: 2); + bool get backendChosen => config?.chosen ?? false; bool get isAuthenticated => session?.isValid ?? false; bool get isPaired => paired != null; @@ -59,17 +118,95 @@ 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 + 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() { + _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(); @@ -78,8 +215,20 @@ 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()); + // Notification relay (Android only; inert + invisible elsewhere). Best-effort. + unawaited(notificationRelay.bootstrap()); 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 + // _reconnect rather than openSession. + if (isAuthenticated) _startFlusher(); if (isAuthenticated && isPaired) openSession(); } @@ -92,6 +241,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(); @@ -137,6 +287,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(); } @@ -148,22 +299,112 @@ 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(); await session!.clear(); notifyListeners(); } + /// 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.) + /// + /// 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; + await IosBleRestore.setOwnsBand(false); + await IosBleRestore.arm(paired!.remoteId); + } + Future _onRecord(Sample? sample, RawRecord raw) async { + // Spot-check: tap the live RR-bearing frames (0x28 compact HR, 0x2B R10) into + // the in-memory scan buffer. Cheap-bounded; cleared at each scan start. + if (spotActive && (raw.packetType == 0x28 || raw.packetType == 0x2B)) { + if (_spotFrames.length < 8000) _spotFrames.add(raw.hex); + } await LocalDb.insertRecord(raw, sample); } + /// Start the session-long flusher (idempotent). On a COLD start (no timer yet) it kicks + /// once immediately so the first session doesn't wait a full interval, then runs every + /// _kFlushInterval. If the timer is already running — foreground-reclaim or a + /// mid-session reconnect, since the flusher survives both — this is a no-op and the + /// already-running timer carries the upload on its next tick (≤_kFlushInterval). + /// uploadPending() no-ops cheaply when nothing is queued, so the tick is unconditional. + /// Stopped on session teardown only (logout / signOut / unpair / endSession). + void _startFlusher() { + if (_flushTimer != null) return; // already running — timer survives background + reconnect + if (!uploading) unawaited(upload()); // immediate kick on cold start + _flushTimer = Timer.periodic(_kFlushInterval, (_) { + if (!uploading) unawaited(upload()); + }); + } + + void _stopFlusher() { + _flushTimer?.cancel(); + _flushTimer = null; + } + 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…'); + // 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(); } } @@ -177,6 +418,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') && @@ -190,8 +434,9 @@ 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(); await PairedDevice.clear(); @@ -229,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); @@ -240,12 +493,47 @@ 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. The + // flusher kept running through the background, so uploads are already current; + // _startFlusher() below is a no-op (timer alive) and no explicit flush is needed. + 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(); // timer was running through background; this is a no-op if so + // iOS can resume with the peripheral still flagged "connected" while its GATT + // notifications died during suspension — UI shows connected but NO events arrive, + // and only a kill+reopen (full reconnect) recovers. Trust DATA, not the flag: if a + // notification arrived recently the link is genuinely live → keep the fast reclaim. + // Otherwise it's stale → tear it down and fall through to a clean reconnect, which + // re-subscribes (the only place setNotifyValue runs) and drains the gap. + if (engine.sinceLastRx < const Duration(seconds: 30)) { + // Healthy link → fast reclaim. But the fast path skips the band polls the full + // connect path runs, so the cached battery %/charging/strap-name/alarm go stale + // (charge only refreshed on a cold restart). Re-poll them in the background so the + // UI stays current without forcing a reconnect. Non-blocking; failures are benign. + unawaited(() async { + try { + await engine.getBattery(); + await engine.getStrapName(); + await engine.getAlarm(); + } catch (_) {} + }()); + return; + } + _log('Resume: no BLE data for ${engine.sinceLastRx.inSeconds}s — stale link, reconnecting.'); + await engine.disconnect(); + // fall through to the full connect → subscribe → drain path below + } _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. - 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; @@ -263,17 +551,16 @@ 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(); - await upload(); + // No explicit upload here — the flusher (kicked on start, then every 60s) is the + // single upload path; the freshly-drained backlog goes up on its next tick. } catch (e) { lastError = e.toString(); } finally { @@ -285,14 +572,32 @@ 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)) { - await engine.runSync(timeout: const Duration(seconds: 30)); - await upload(); + // 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 + // 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 engine.enableLiveStreams(); - _log('Reconnected.'); + dbCounts = await LocalDb.counts(); + // Upload handled by the flusher (single path) — runs within the next tick. + _log('Reconnected — backlog drained.'); break; } } @@ -303,10 +608,27 @@ 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(); + dbCounts = await LocalDb.counts(); + notifyListeners(); + // Upload handled by the always-running flusher (single path), next tick ≤60s. + } catch (e) { + _log('Resync failed: $e'); + } + } + Future syncNow() => openSession(); Future endSession() async { _keepAlive = false; + _stopFlusher(); await engine.disconnect(); } @@ -326,22 +648,44 @@ class AppState extends ChangeNotifier { if (uploading) return; uploading = true; notifyListeners(); + bool progressed = false; try { - await _uploadInner(); + progressed = await _uploadInner(); } finally { uploading = false; notifyListeners(); } + // Backfill fast-drain: after a successful, progress-making pass that still leaves a + // large backlog (the band kept feeding flash records while we uploaded), re-drain + // promptly instead of waiting the full trickle interval. Gated on `progressed` so a + // 429 / network failure falls back to the steady 60 s timer's retry rather than + // spinning, and on `_flushTimer != null` so it only runs during an active session. + if (progressed && + _flushTimer != null && + (dbCounts['pending'] ?? 0) > kBacklogThreshold) { + Timer(_kBackfillFlushDelay, () { + if (_flushTimer != null && !uploading) unawaited(upload()); + }); + } } - Future _uploadInner() async { + /// Returns true if a raw-record upload pass succeeded AND moved records (i.e. made + /// progress) — used to decide whether to keep fast-draining a backlog. + Future _uploadInner() async { final uploader = Uploader(api!); - final result = await uploader.uploadPending(onChunk: () async { + // Size the batch to the current backlog: big batches blast through a night's drain, + // small batches keep the steady trickle's payloads tiny (see uploader.dart). + final pending = (await LocalDb.counts())['pending'] ?? 0; + final result = await uploader.uploadPending( + batchSize: batchSizeForBacklog(pending), onChunk: () async { dbCounts = await LocalDb.counts(); 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!); @@ -354,6 +698,7 @@ class AppState extends ChangeNotifier { } dbCounts = await LocalDb.counts(); notifyListeners(); + return result.ok && result.attempted > 0; } void _setBusy(bool b) { @@ -367,6 +712,126 @@ class AppState extends ChangeNotifier { return state == BluetoothAdapterState.on; } + // ── live HRV spot-check ────────────────────────────────────────────────────── + // User taps "spot check": we enable wrist-gated optical + realtime records, + // collect live frames for [spotDuration]s, then POST them to /spotcheck which + // decodes RR + computes HRV server-side. Ephemeral — nothing is stored. + static const int spotDuration = 60; + bool spotActive = false; + int spotRemaining = 0; // seconds left in the current scan + Map? spotResult; // last result {rmssd, sdnn, mean_hr, n_beats, ok} + String? spotError; + final List _spotFrames = []; + Timer? _spotTimer; + bool _spotEnabledStreams = false; // did WE turn streams on (so we turn them off) + + /// Begin a 60s live HRV reading. Requires a connected band. + Future startSpotCheck() async { + if (spotActive) return; + if (!isConnected) { spotError = 'Connect your band first.'; notifyListeners(); return; } + spotActive = true; + spotError = null; + spotResult = null; + spotRemaining = spotDuration; + _spotFrames.clear(); + notifyListeners(); + try { + // If a workout is already streaming, reuse it; else turn streams on ourselves. + if (activeWorkout == null) { await engine.enableLiveStreams(); _spotEnabledStreams = true; } + } catch (_) {/* best-effort; we still collect whatever arrives */} + _spotTimer?.cancel(); + _spotTimer = Timer.periodic(const Duration(seconds: 1), (_) { + spotRemaining -= 1; + if (spotRemaining <= 0) { unawaited(_finishSpotCheck()); } else { notifyListeners(); } + }); + } + + /// Abort an in-progress scan without computing. + void cancelSpotCheck() { + if (!spotActive) return; + _spotTimer?.cancel(); + _spotTimer = null; + spotActive = false; + spotRemaining = 0; + _stopSpotStreams(); + notifyListeners(); + } + + Future _finishSpotCheck() async { + _spotTimer?.cancel(); + _spotTimer = null; + spotRemaining = 0; + _stopSpotStreams(); + final frames = List.from(_spotFrames); + notifyListeners(); + try { + final res = api == null || frames.isEmpty + ? null + : await api!.spotCheck(frames); + spotResult = res; + if (res == null) { + spotError = 'No reading captured — keep the band snug and still.'; + } else if (res['ok'] != true) { + spotError = 'Not enough clean beats — try again, sitting still.'; + } + } catch (e) { + spotError = 'Spot check failed: ${e is ApiException ? e.body : e}'; + } finally { + spotActive = false; + notifyListeners(); + } + } + + void _stopSpotStreams() { + if (_spotEnabledStreams && activeWorkout == null) { + unawaited(engine.disableLiveStreams()); + } + _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; @@ -392,12 +857,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(); @@ -429,6 +896,75 @@ 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) ───────────────────────────────────────────── + // 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() { @@ -437,6 +973,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): @@ -485,10 +1022,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; + int maxHrSeen = 0; // peak live HR this session (for the "new max!" moment) - LiveWorkoutState({required this.startTime, required this.targetKcal}); + LiveWorkoutState({ + required this.startTime, + required this.targetKcal, + this.workoutId, + this.type = 'other', + }); } diff --git a/lib/state/units_controller.dart b/lib/state/units_controller.dart new file mode 100644 index 0000000..1a3c03b --- /dev/null +++ b/lib/state/units_controller.dart @@ -0,0 +1,87 @@ +// Units controller — a LOCAL display preference (metric / imperial). The backend +// always stores and returns metric (kg, cm); this only changes how values are +// shown and how edit fields are parsed. Persisted on-device via SharedPreferences, +// mirroring ThemeController. Nothing here ever touches the server. + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +enum UnitSystem { metric, imperial } + +extension UnitSystemLabel on UnitSystem { + String get label => this == UnitSystem.imperial ? 'Imperial' : 'Metric'; +} + +class UnitsController extends ChangeNotifier { + static const String _kUnits = 'units_system'; // 'metric' | 'imperial' + static const double _kgPerLb = 0.45359237; + static const double _cmPerIn = 2.54; + + UnitSystem _system; + UnitsController._(this._system); + + factory UnitsController.seed(UnitSystem s) => UnitsController._(s); + + static Future bootstrap() async { + final prefs = await SharedPreferences.getInstance(); + return UnitsController._(_parse(prefs.getString(_kUnits))); + } + + static UnitSystem _parse(String? s) => + s == 'imperial' ? UnitSystem.imperial : UnitSystem.metric; + + UnitSystem get system => _system; + bool get isImperial => _system == UnitSystem.imperial; + + Future setSystem(UnitSystem s) async { + if (_system == s) return; + _system = s; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kUnits, s.name); + } + + // ── display (input is always metric, as stored) ──────────────────────────── + String _trim(num v) => + v == v.roundToDouble() ? v.round().toString() : v.toStringAsFixed(1); + + /// "70 kg" / "154 lb" / "—". + String weight(num? kg) { + if (kg == null) return '—'; + return isImperial ? '${(kg / _kgPerLb).round()} lb' : '${_trim(kg)} kg'; + } + + /// "180 cm" / "5′11″" / "—". + String height(num? cm) { + if (cm == null) return '—'; + if (!isImperial) return '${_trim(cm)} cm'; + final totalIn = (cm / _cmPerIn).round(); + return "${totalIn ~/ 12}′${totalIn % 12}″"; + } + + // ── edit-field helpers (display ↔ metric for storage) ────────────────────── + String get weightLabel => isImperial ? 'Weight (lb)' : 'Weight (kg)'; + String get heightLabel => isImperial ? 'Height (in)' : 'Height (cm)'; + + /// Pre-fill value for the weight field in the user's units. + String weightField(num? kg) => + kg == null ? '' : (isImperial ? (kg / _kgPerLb).round().toString() : _trim(kg)); + + /// Pre-fill value for the height field in the user's units (total inches). + String heightField(num? cm) => + cm == null ? '' : (isImperial ? (cm / _cmPerIn).round().toString() : _trim(cm)); + + /// Parse a weight field (display units) → kg for storage. + double? weightToKg(String text) { + final v = double.tryParse(text.trim()); + if (v == null) return null; + return isImperial ? v * _kgPerLb : v; + } + + /// Parse a height field (display units = total inches when imperial) → cm. + double? heightToCm(String text) { + final v = double.tryParse(text.trim()); + if (v == null) return null; + return isImperial ? v * _cmPerIn : v; + } +} diff --git a/lib/sync/background_sync.dart b/lib/sync/background_sync.dart index 9686fff..e1192d2 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 { @@ -38,7 +36,8 @@ Future runHeadlessSync() async { // 1. Flush any backlog first — covers the case where a prior run captured // records but the upload leg failed (offline, etc.). - await uploader.uploadPending(); + await uploader.uploadPending( + batchSize: batchSizeForBacklog((await LocalDb.counts())['pending'] ?? 0)); await uploader.uploadEvents(); // 2. Connect → drain → upload. No live streams (battery): in and out. @@ -56,8 +55,15 @@ Future runHeadlessSync() async { return true; } try { - await engine.runSync(timeout: const Duration(seconds: 120)); - await uploader.uploadPending(); + // 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(); + // Post-drain: the band's offline flash backlog is now in local DB — a full night + // is thousands of records, so size up the batch (backfill mode). + await uploader.uploadPending( + batchSize: batchSizeForBacklog((await LocalDb.counts())['pending'] ?? 0)); await uploader.uploadEvents(); } finally { await engine.disconnect(); @@ -69,38 +75,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/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 (_) {} + } +} 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/sync/uploader.dart b/lib/sync/uploader.dart index b7f7950..121ddb4 100644 --- a/lib/sync/uploader.dart +++ b/lib/sync/uploader.dart @@ -6,6 +6,22 @@ import '../data/db.dart'; import '../net/api_client.dart'; +// ── Backfill-aware batch sizing ──────────────────────────────────────────────── +// The steady 1 Hz trickle and the morning drain of a full night the band buffered +// while disconnected are two very different workloads. The backend rate limit counts +// per POST (30 req / 60 s / user), NOT per record, and the day-packed minute store does +// the same D1 work whatever the batch size — so a large backlog uploads in BIGGER +// batches to lift the effective record ceiling ~5× (30×300 ≈ 9k/min → 30×1500 ≈ 45k/min) +// while the trickle stays on small batches to keep payloads tiny. ~1.5k caps payload at +// a few hundred KB (well under the 100 MB Worker body limit) with CPU headroom to spare. +const int kTrickleBatch = 300; +const int kBackfillBatch = 1500; +const int kBacklogThreshold = 1000; // pending records above which we switch to backfill + +/// Pick an upload batch size from the current pending-record backlog. +int batchSizeForBacklog(int pending) => + pending > kBacklogThreshold ? kBackfillBatch : kTrickleBatch; + class UploadResult { final int attempted; final int accepted; @@ -30,7 +46,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/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 cb21079..c8fd5cf 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'; @@ -9,558 +12,536 @@ 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; 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); +} + +final 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(themedRoute((_) => 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(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/activity/strain_detail_screen.dart b/lib/ui/activity/strain_detail_screen.dart index fd1843c..a506691 100644 --- a/lib/ui/activity/strain_detail_screen.dart +++ b/lib/ui/activity/strain_detail_screen.dart @@ -10,10 +10,14 @@ 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' - const StrainDetailScreen({super.key, required this.date}); + // 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 State createState() => _StrainDetailScreenState(); } @@ -131,8 +135,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 +163,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 +193,22 @@ 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), + ], + ..._fitnessSection(), _curveCard(), const SizedBox(height: Sp.x4), _zonesCard(), @@ -199,9 +216,86 @@ 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() ?? ''), + ])), + ], ]; } + // 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(); + 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: [ + 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() { @@ -221,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), @@ -279,7 +373,7 @@ class _StrainDetailScreenState extends State { Widget _zonesCard() { final zones = _zones(); - const palette = [ + final palette = [ AppColors.loadDetraining, AppColors.good, AppColors.warn, @@ -378,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( @@ -421,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( @@ -467,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)), @@ -482,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, ), @@ -499,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)); +} 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/coach/ai_coach_screen.dart b/lib/ui/coach/ai_coach_screen.dart new file mode 100644 index 0000000..76153d0 --- /dev/null +++ b/lib/ui/coach/ai_coach_screen.dart @@ -0,0 +1,372 @@ +// The AI Coach chat (BYOK, agentic). Runs CoachEngine, renders interleaved text + +// animated charts, and surfaces write-actions for explicit confirmation. Distinct +// from the rule-based CoachScreen (deterministic plan). + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:gpt_markdown/gpt_markdown.dart'; + +import '../../coach/coach_config.dart'; +import '../../coach/coach_engine.dart'; +import '../../state/app_state.dart'; +import '../../theme/theme.dart'; +import '../../theme/tokens.dart'; +import '../kit/kit.dart'; +import 'coach_chart.dart'; +import 'coach_settings_screen.dart'; + +class AiCoachScreen extends StatefulWidget { + const AiCoachScreen({super.key}); + @override + State createState() => _AiCoachScreenState(); +} + +class _AiCoachScreenState extends State { + CoachEngine? _engine; + final List _items = []; + final _input = TextEditingController(); + final _scroll = ScrollController(); + bool _busy = false; + String? _status; + + static const _starters = [ + 'How recovered am I today, and why?', + 'Show my HRV trend this month', + 'Compare my resting HR vs HRV over 2 weeks', + 'How has my sleep been this week?', + 'What should my training look like today?', + ]; + + @override + void initState() { + super.initState(); + _initEngine(); + } + + Future _initEngine() async { + final app = context.read(); + final cfg = context.read(); + final api = app.api; + if (api == null) return; + final uid = (app.user?['id'] ?? 'anon').toString(); + final engine = CoachEngine(config: cfg, api: api, storageKey: uid); + await engine.restore(); + if (!mounted) return; + setState(() { + _engine = engine; + _items + ..clear() + ..addAll(engine.transcript); + }); + _scrollDown(); + } + + @override + void dispose() { + _input.dispose(); + _scroll.dispose(); + _engine?.dispose(); + super.dispose(); + } + + Future _send(String text) async { + final t = text.trim(); + if (t.isEmpty || _busy) return; + final engine = _engine; + if (engine == null) return; + _input.clear(); + setState(() => _busy = true); + try { + await engine.send( + t, + onItem: (it) { setState(() => _items.add(it)); _scrollDown(); }, + onStatus: (s) => setState(() => _status = s), + confirm: _confirm, + ); + } catch (e) { + setState(() => _items.add(CoachItem.error( + e is CoachException ? e.message : 'Something went wrong: $e'))); + } finally { + if (mounted) setState(() { _busy = false; _status = null; }); + await engine.persist(); // survive reopen + _scrollDown(); + } + } + + void _newChat() { + _engine?.newSession(); + setState(() => _items.clear()); + } + + void _openSession(String id) async { + await _engine?.openSession(id); + if (mounted) { + setState(() => _items + ..clear() + ..addAll(_engine?.transcript ?? const [])); + _scrollDown(); + } + } + + Future _openSessions() async { + final engine = _engine; + if (engine == null) return; + await showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + showDragHandle: true, + isScrollControlled: true, + builder: (sheetCtx) => StatefulBuilder( + builder: (sheetCtx, setSheet) => FutureBuilder>( + future: engine.listSessions(), + builder: (_, snap) { + final sessions = snap.data ?? const []; + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.6, + maxChildSize: 0.9, + builder: (_, controller) => ListView( + controller: controller, + padding: const EdgeInsets.symmetric(horizontal: Sp.screen), + children: [ + Row(children: [ + Text('Chat history', style: AppText.h2), + const Spacer(), + TextButton.icon( + onPressed: () { Navigator.pop(sheetCtx); _newChat(); }, + icon: AppIcon(Ic.plus, size: 16, color: AppColors.coral), + label: Text('New chat', style: AppText.label.copyWith(color: AppColors.coral)), + ), + ]), + const SizedBox(height: Sp.x3), + if (snap.connectionState == ConnectionState.waiting) + const Padding(padding: EdgeInsets.all(Sp.x6), child: Center(child: CircularProgressIndicator())) + else if (sessions.isEmpty) + Padding(padding: const EdgeInsets.all(Sp.x4), + child: Text('No past chats yet.', style: AppText.captionMuted)) + else + for (final s in sessions) + ProCard( + onTap: () { Navigator.pop(sheetCtx); _openSession(s.id); }, + child: Row(children: [ + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(s.title.isEmpty ? 'Untitled chat' : s.title, + style: AppText.label, maxLines: 1, overflow: TextOverflow.ellipsis), + if (s.preview.isNotEmpty) ...[ + const SizedBox(height: 2), + Text(s.preview, style: AppText.captionMuted, maxLines: 1, overflow: TextOverflow.ellipsis), + ], + const SizedBox(height: 2), + Text(_relTime(s.updatedAt), style: AppText.captionMuted), + ])), + IconButton( + icon: AppIcon(Ic.trash, size: 16, color: AppColors.inkMuted), + onPressed: () async { + final wasCurrent = s.id == engine.sessionId; + await engine.deleteSession(s.id); + setSheet(() {}); + // deleting the open chat resets it to a fresh one + if (wasCurrent && mounted) setState(() => _items.clear()); + }, + ), + ]), + ), + const SizedBox(height: 40), + ], + ), + ); + }, + ), + ), + ); + } + + static String _relTime(int ms) { + final d = DateTime.now().difference(DateTime.fromMillisecondsSinceEpoch(ms)); + if (d.inMinutes < 1) return 'just now'; + if (d.inMinutes < 60) return '${d.inMinutes}m ago'; + if (d.inHours < 24) return '${d.inHours}h ago'; + return '${d.inDays}d ago'; + } + + Future _confirm(ActionRequest req) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.surface, + title: Text(req.title, style: AppText.title), + content: Text(req.summary, style: AppText.body), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Confirm')), + ], + ), + ); + return ok ?? false; + } + + void _scrollDown() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scroll.hasClients) { + _scroll.animateTo(_scroll.position.maxScrollExtent, + duration: const Duration(milliseconds: 250), curve: Curves.easeOut); + } + }); + } + + @override + Widget build(BuildContext context) { + final cfg = context.watch(); + final signedIn = context.select((s) => s.api != null); + + return Scaffold( + backgroundColor: AppColors.bg, + body: SafeArea( + child: Column(children: [ + _topBar(cfg), + Expanded( + child: !cfg.configured + ? _setupPrompt() + : !signedIn + ? _centered('Sign in to use the coach.') + : _items.isEmpty + ? _starterView() + : _transcript(), + ), + if (_status != null) _statusBar(), + if (cfg.configured && signedIn) _composer(), + ]), + ), + ); + } + + Widget _topBar(CoachConfig cfg) => Padding( + padding: const EdgeInsets.fromLTRB(Sp.screen, Sp.x4, Sp.screen, Sp.x2), + child: Row(children: [ + RoundIconButton(Ic.arrowLeft, onTap: () => Navigator.of(context).pop()), + const SizedBox(width: Sp.x3), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('AI Coach', style: AppText.h1), + Text(cfg.configured ? cfg.model : 'Not configured', style: AppText.caption), + ])), + RoundIconButton(Ic.history, onTap: _openSessions), + const SizedBox(width: Sp.x2), + RoundIconButton(Ic.plus, onTap: _newChat), + const SizedBox(width: Sp.x2), + RoundIconButton(Ic.settings, onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const CoachSettingsScreen()))), + ]), + ); + + Widget _setupPrompt() => Center(child: Padding( + padding: const EdgeInsets.all(Sp.x6), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + AppIcon(Ic.ai, size: 40, color: AppColors.coral), + const SizedBox(height: Sp.x4), + Text('Bring your own AI', style: AppText.h2), + const SizedBox(height: Sp.x3), + Text('Use any OpenAI-compatible provider with your own API key. Your key ' + 'stays on this device and talks to the provider directly — it never ' + 'touches OpenStrap servers.', + textAlign: TextAlign.center, style: AppText.bodySoft), + const SizedBox(height: Sp.x5), + FilledButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const CoachSettingsScreen())), + child: const Text('Set up your AI coach'), + ), + ]), + )); + + Widget _starterView() => ListView( + padding: const EdgeInsets.all(Sp.screen), + children: [ + const SizedBox(height: Sp.x4), + Text('Ask me anything about your health', style: AppText.h2), + const SizedBox(height: Sp.x2), + Text('I can read every metric in your account and chart it.', style: AppText.captionMuted), + const SizedBox(height: Sp.x5), + for (final s in _starters) + Padding( + padding: const EdgeInsets.only(bottom: Sp.x3), + child: ProCard(onTap: () => _send(s), child: Row(children: [ + Expanded(child: Text(s, style: AppText.body)), + AppIcon(Ic.arrowRight, size: 16, color: AppColors.inkMuted), + ])), + ), + ], + ); + + Widget _transcript() => ListView.builder( + controller: _scroll, + padding: const EdgeInsets.all(Sp.screen), + itemCount: _items.length, + itemBuilder: (_, i) => _bubble(_items[i]), + ); + + Widget _bubble(CoachItem it) { + switch (it.kind) { + case CoachItemKind.user: + return Align( + alignment: Alignment.centerRight, + child: Container( + margin: const EdgeInsets.only(bottom: Sp.x3, left: 40), + padding: const EdgeInsets.symmetric(horizontal: Sp.x4, vertical: Sp.x3), + decoration: BoxDecoration(color: AppColors.coralSoft, borderRadius: BorderRadius.circular(R.card)), + child: Text(it.text ?? '', style: AppText.body.copyWith(color: AppColors.coralInk)), + ), + ); + case CoachItemKind.assistant: + return Padding( + padding: const EdgeInsets.only(bottom: Sp.x4, right: 8), + child: GptMarkdown(it.text ?? '', style: AppText.body), + ); + case CoachItemKind.chart: + return Padding( + padding: const EdgeInsets.only(bottom: Sp.x4), + child: CoachChart(spec: it.chart!), + ); + case CoachItemKind.error: + return Padding( + padding: const EdgeInsets.only(bottom: Sp.x4), + child: ProCard(child: Row(children: [ + AppIcon(Ic.info, size: 16, color: AppColors.warn), + const SizedBox(width: Sp.x3), + Expanded(child: Text(it.text ?? '', style: AppText.captionMuted)), + ])), + ); + } + } + + Widget _statusBar() => Padding( + padding: const EdgeInsets.symmetric(horizontal: Sp.screen, vertical: Sp.x2), + child: Row(children: [ + const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)), + const SizedBox(width: Sp.x3), + Text(_status ?? '', style: AppText.captionMuted), + ]), + ); + + Widget _composer() => Padding( + padding: EdgeInsets.fromLTRB(Sp.screen, Sp.x2, Sp.screen, + Sp.x3 + MediaQuery.of(context).viewInsets.bottom), + child: Row(children: [ + Expanded(child: TextField( + controller: _input, + minLines: 1, maxLines: 4, + textInputAction: TextInputAction.send, + onSubmitted: _busy ? null : _send, + decoration: const InputDecoration(hintText: 'Ask about your health…'), + )), + const SizedBox(width: Sp.x3), + FilledButton( + onPressed: _busy ? null : () => _send(_input.text), + child: const AppIcon(Ic.arrowRight, size: 18, color: Colors.white), + ), + ]), + ); + + Widget _centered(String msg) => Center(child: Padding( + padding: const EdgeInsets.all(Sp.x6), + child: Text(msg, textAlign: TextAlign.center, style: AppText.bodySoft))); +} diff --git a/lib/ui/coach/coach_chart.dart b/lib/ui/coach/coach_chart.dart new file mode 100644 index 0000000..119a453 --- /dev/null +++ b/lib/ui/coach/coach_chart.dart @@ -0,0 +1,182 @@ +// Renders a ChartSpec (built by the coach from real data) as an ANIMATED native +// chart, using the app's look. Bars reuse LabeledBars; line/area use an animated +// multi-series painter. Honest: it only ever plots the numbers the model passed. + +import 'dart:math' as math; +import 'package:flutter/material.dart'; + +import '../../coach/coach_engine.dart'; +import '../../theme/theme.dart'; +import '../../theme/tokens.dart'; +import '../kit/kit.dart'; +import '../kit/charts.dart'; + +class CoachChart extends StatelessWidget { + final ChartSpec spec; + const CoachChart({super.key, required this.spec}); + + List get _colors => [ + AppColors.coral, + AppColors.good, + AppColors.loadDetraining, + AppColors.warn, + AppColors.coralDeep, + ]; + + @override + Widget build(BuildContext context) { + final single = spec.series.length == 1; + return ProCard( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (spec.title.isNotEmpty) Text(spec.title, style: AppText.title), + if (spec.unit.isNotEmpty) ...[ + const SizedBox(height: 2), + Text(spec.unit, style: AppText.captionMuted), + ], + const SizedBox(height: Sp.x4), + if (spec.type == 'bar' && single) + LabeledBars( + values: spec.series.first.values.map((v) => v ?? 0).toList(), + labels: _fitLabels(spec.xLabels, spec.series.first.values.length), + color: AppColors.coral, + height: 160, + ) + else + _AnimatedLineChart(spec: spec, colors: _colors, filled: spec.type == 'area'), + if (!single) ...[ + const SizedBox(height: Sp.x3), + Wrap(spacing: Sp.x4, runSpacing: Sp.x2, children: [ + for (int i = 0; i < spec.series.length; i++) + Row(mainAxisSize: MainAxisSize.min, children: [ + Container(width: 9, height: 9, decoration: BoxDecoration( + color: _colors[i % _colors.length], shape: BoxShape.circle)), + const SizedBox(width: Sp.x2), + Text(spec.series[i].name, style: AppText.caption), + ]), + ]), + ], + if (spec.note != null && spec.note!.isNotEmpty) ...[ + const SizedBox(height: Sp.x3), + Text(spec.note!, style: AppText.captionMuted), + ], + ]), + ); + } + + List _fitLabels(List labels, int n) { + if (labels.length == n) return labels; + return List.generate(n, (i) => i < labels.length ? labels[i] : ''); + } +} + +class _AnimatedLineChart extends StatelessWidget { + final ChartSpec spec; + final List colors; + final bool filled; + const _AnimatedLineChart({required this.spec, required this.colors, required this.filled}); + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + duration: const Duration(milliseconds: 700), + curve: Curves.easeOutCubic, + tween: Tween(begin: 0, end: 1), + builder: (_, t, _) => SizedBox( + height: 170, + width: double.infinity, + child: CustomPaint( + painter: _LinePainter(spec: spec, colors: colors, filled: filled, progress: t, + grid: AppColors.divider, label: AppColors.inkMuted), + ), + ), + ); + } +} + +class _LinePainter extends CustomPainter { + final ChartSpec spec; + final List colors; + final bool filled; + final double progress; + final Color grid; + final Color label; + _LinePainter({required this.spec, required this.colors, required this.filled, + required this.progress, required this.grid, required this.label}); + + @override + void paint(Canvas canvas, Size size) { + final all = []; + for (final s in spec.series) { + for (final v in s.values) { + if (v != null) all.add(v); + } + } + if (all.isEmpty) return; + var lo = all.reduce(math.min), hi = all.reduce(math.max); + if (lo == hi) { lo -= 1; hi += 1; } + final pad = (hi - lo) * 0.1; + lo -= pad; hi += pad; + + const leftPad = 4.0, bottomPad = 16.0, topPad = 4.0; + final chartW = size.width - leftPad; + final chartH = size.height - bottomPad - topPad; + + // baseline grid + final gridPaint = Paint()..color = grid..strokeWidth = 1; + for (int g = 0; g <= 2; g++) { + final y = topPad + chartH * g / 2; + canvas.drawLine(Offset(leftPad, y), Offset(size.width, y), gridPaint); + } + + final maxLen = spec.series.map((s) => s.values.length).fold(0, math.max); + if (maxLen < 1) return; + // guard the single-point case (no divide-by-zero) → place it centered. + double xAt(int i) => maxLen == 1 ? leftPad + chartW / 2 : leftPad + chartW * i / (maxLen - 1); + double yAt(double v) => topPad + chartH * (1 - (v - lo) / (hi - lo)); + + canvas.save(); + canvas.clipRect(Rect.fromLTWH(0, 0, size.width * progress, size.height)); + for (int si = 0; si < spec.series.length; si++) { + final s = spec.series[si]; + final color = colors[si % colors.length]; + final path = Path(); + final pts = []; + bool started = false; + for (int i = 0; i < s.values.length; i++) { + final v = s.values[i]; + if (v == null) continue; + final pt = Offset(xAt(i), yAt(v)); + pts.add(pt); + if (!started) { path.moveTo(pt.dx, pt.dy); started = true; } + else { path.lineTo(pt.dx, pt.dy); } + } + if (!started) continue; + + if (filled && pts.length > 1) { + final fill = Path.from(path) + ..lineTo(pts.last.dx, topPad + chartH) + ..lineTo(pts.first.dx, topPad + chartH) + ..close(); + canvas.drawPath(fill, Paint()..color = color.withValues(alpha: 0.14)..style = PaintingStyle.fill); + } + // the line (only meaningful with ≥2 points) + if (pts.length > 1) { + canvas.drawPath(path, Paint() + ..color = color + ..strokeWidth = 2.5 + ..style = PaintingStyle.stroke + ..strokeJoin = StrokeJoin.round + ..strokeCap = StrokeCap.round); + } + // dots at every point so single values + vertices are always visible + final dot = Paint()..color = color..style = PaintingStyle.fill; + for (final p in pts) { + canvas.drawCircle(p, pts.length == 1 ? 5 : 3, dot); + } + } + canvas.restore(); + } + + @override + bool shouldRepaint(covariant _LinePainter old) => old.progress != progress || old.spec != spec; +} 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/coach/coach_settings_screen.dart b/lib/ui/coach/coach_settings_screen.dart new file mode 100644 index 0000000..43b9b2c --- /dev/null +++ b/lib/ui/coach/coach_settings_screen.dart @@ -0,0 +1,225 @@ +// AI Coach settings — BYOK. Enter an OpenAI-compatible base URL + API key, then +// fetch the provider's model list (GET /models) and pick one. Key is stored in the +// device keychain; nothing here touches OpenStrap servers. + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../coach/coach_config.dart'; +import '../../coach/coach_engine.dart'; +import '../../theme/theme.dart'; +import '../../theme/tokens.dart'; +import '../kit/kit.dart'; + +class CoachSettingsScreen extends StatefulWidget { + const CoachSettingsScreen({super.key}); + @override + State createState() => _CoachSettingsScreenState(); +} + +class _CoachSettingsScreenState extends State { + late final TextEditingController _base; + late final TextEditingController _key; + late final TextEditingController _query; // search / free-type box + String _model = ''; // committed model id (from list or typed) + bool _obscure = true; + bool _loadingModels = false; + List _models = const []; + String? _msg; + + @override + void initState() { + super.initState(); + final cfg = context.read(); + _base = TextEditingController(text: cfg.baseUrl); + _key = TextEditingController(text: cfg.apiKey ?? ''); + _query = TextEditingController(); + _model = cfg.model; + } + + @override + void dispose() { + _base.dispose(); + _key.dispose(); + _query.dispose(); + super.dispose(); + } + + Future _fetchModels() async { + if (_key.text.trim().isEmpty) { + setState(() => _msg = 'Enter your API key first.'); + return; + } + setState(() { _loadingModels = true; _msg = null; }); + try { + final ids = await CoachEngine.fetchModels(_base.text, _key.text); + setState(() { + _models = ids; + _msg = ids.isEmpty ? 'Provider returned no models — type one manually.' : '${ids.length} models found. Search and tap to pick.'; + }); + } catch (e) { + setState(() => _msg = e is CoachException ? e.message : 'Could not list models: $e'); + } finally { + if (mounted) setState(() => _loadingModels = false); + } + } + + // The chosen model = a tapped/ticked list item, else whatever was typed. + String get _chosen => _model.isNotEmpty ? _model : _query.text.trim(); + + Future _save() async { + final cfg = context.read(); + if (_chosen.isEmpty) { + setState(() => _msg = 'Pick or type a model first.'); + return; + } + await cfg.save(baseUrl: _base.text, apiKey: _key.text, model: _chosen); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('AI Coach settings saved.'))); + Navigator.of(context).pop(); + } + } + + // Searchable list of fetched models + a "use what I typed" custom row. The row + // matching the committed model shows a tick. + Widget _modelList() { + final q = _query.text.trim(); + final ql = q.toLowerCase(); + final filtered = ql.isEmpty ? _models : _models.where((m) => m.toLowerCase().contains(ql)).toList(); + final exact = _models.any((m) => m.toLowerCase() == ql); + + final rows = []; + if (q.isNotEmpty && !exact) rows.add(_modelRow(q, custom: true)); + for (final m in filtered.take(80)) { + rows.add(_modelRow(m)); + } + + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (_model.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: Sp.x2), + child: Row(children: [ + AppIcon(Ic.check, size: 14, color: AppColors.coral), + const SizedBox(width: Sp.x2), + Expanded(child: Text('Using: $_model', + style: AppText.caption.copyWith(color: AppColors.coralInk))), + ]), + ), + if (rows.isEmpty) + Text(_models.isEmpty + ? 'Tap Fetch to load your provider’s models — or just type a model id above and it’ll be used as-is.' + : 'No match. Type a full model id to use it as a custom model.', + style: AppText.captionMuted) + else + Container( + constraints: const BoxConstraints(maxHeight: 280), + decoration: BoxDecoration( + color: AppColors.surfaceSunk, borderRadius: BorderRadius.circular(R.card)), + child: ListView(shrinkWrap: true, padding: const EdgeInsets.symmetric(vertical: Sp.x2), children: rows), + ), + ]); + } + + Widget _modelRow(String id, {bool custom = false}) { + final selected = _model == id; + return InkWell( + onTap: () => setState(() => _model = id), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Sp.x4, vertical: Sp.x3), + child: Row(children: [ + if (custom) ...[ + AppIcon(Ic.edit, size: 13, color: AppColors.inkMuted), + const SizedBox(width: Sp.x2), + ], + Expanded(child: Text(custom ? 'Use “$id”' : id, + style: AppText.body.copyWith(color: selected ? AppColors.coralInk : AppColors.ink))), + if (selected) AppIcon(Ic.check, size: 18, color: AppColors.coral), + ]), + ), + ); + } + + @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), + Row(children: [ + RoundIconButton(Ic.arrowLeft, onTap: () => Navigator.of(context).pop()), + const SizedBox(width: Sp.x3), + Text('AI Coach', style: AppText.h1), + ]), + const SizedBox(height: Sp.x5), + + ProCard(child: Row(children: [ + AppIcon(Ic.shield, size: 18, color: AppColors.good), + const SizedBox(width: Sp.x3), + Expanded(child: Text('Your key is stored only on this device and is sent ' + 'directly to your provider — never to OpenStrap.', style: AppText.captionMuted)), + ])), + const SizedBox(height: Sp.x5), + + const SectionHeader('Provider'), + TextField( + controller: _base, + decoration: const InputDecoration( + labelText: 'Base URL', + hintText: 'https://api.openai.com/v1', + ), + ), + const SizedBox(height: Sp.x3), + TextField( + controller: _key, + obscureText: _obscure, + decoration: InputDecoration( + labelText: 'API key', + suffixIcon: IconButton( + icon: AppIcon(_obscure ? Ic.info : Ic.check, size: 18, color: AppColors.inkMuted), + onPressed: () => setState(() => _obscure = !_obscure), + ), + ), + ), + const SizedBox(height: Sp.x3), + Text('Works with OpenAI, OpenRouter, Groq, Together, local Ollama / LM Studio, ' + 'and anything OpenAI-compatible.', style: AppText.captionMuted), + + const SizedBox(height: Sp.x5), + const SectionHeader('Model'), + Row(children: [ + Expanded(child: TextField( + controller: _query, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: 'Search or type a model', + hintText: 'e.g. minimax, gpt-4o, llama', + ), + )), + const SizedBox(width: Sp.x3), + OutlinedButton( + onPressed: _loadingModels ? null : _fetchModels, + child: _loadingModels + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Fetch'), + ), + ]), + const SizedBox(height: Sp.x3), + _modelList(), + if (_msg != null) ...[ + const SizedBox(height: Sp.x3), + Text(_msg!, style: AppText.captionMuted), + ], + + const SizedBox(height: Sp.x7), + FilledButton(onPressed: _save, child: const Text('Save')), + const SizedBox(height: 60), + ], + ), + ), + ); + } +} diff --git a/lib/ui/cycle/cycle_screen.dart b/lib/ui/cycle/cycle_screen.dart new file mode 100644 index 0000000..56584ad --- /dev/null +++ b/lib/ui/cycle/cycle_screen.dart @@ -0,0 +1,315 @@ +// Cycle — menstrual cycle tracking. Log period starts; see current phase, next +// predicted period + fertile window (log-anchored calendar method), and how your +// skin-temp / resting-HR / HRV shift across the cycle. Honest: an estimate, not +// medical or contraceptive guidance. Uses getCycle / postCycleLog / deleteCycleLog. + +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 CycleScreen extends StatefulWidget { + const CycleScreen({super.key}); + @override + State createState() => _CycleScreenState(); +} + +class _CycleScreenState extends State { + Map? _data; + bool _loading = true; + bool _noApi = false; + String? _error; + + ApiClient? get _api => context.read().api; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final api = _api; + if (api == null) { + setState(() { _loading = false; _noApi = true; }); + return; + } + setState(() { _loading = true; _error = null; _noApi = false; }); + try { + final d = await api.getCycle(); + if (!mounted) return; + setState(() { _data = d; _loading = false; }); + } catch (e) { + if (!mounted) return; + setState(() { _loading = false; _error = e is ApiException ? e.body : e.toString(); }); + } + } + + static String _fmt(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; + + Future _logToday() => _logDate(DateTime.now().toUtc()); + + Future _logDate(DateTime day) async { + final api = _api; + if (api == null) { return; } + try { + await api.postCycleLog(_fmt(day), kind: 'start'); + await _load(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Couldn't log: ${e is ApiException ? e.body : e}"))); + } + } + } + + Future _pickAndLog() async { + final now = DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: now, + firstDate: DateTime(now.year - 2), + lastDate: now, + ); + if (picked != null) await _logDate(picked.toUtc()); + } + + Future _delete(String date) async { + final api = _api; + if (api == null) return; + try { + await api.deleteCycleLog(date); + await _load(); + } catch (_) {} + } + + // ── phase presentation ────────────────────────────────────────────────────── + static const _phaseLabel = { + 'menstruation': 'Menstruation', + 'follicular': 'Follicular phase', + 'ovulation': 'Ovulation window', + 'luteal': 'Luteal phase', + 'unknown': 'Cycle', + }; + Color _phaseColor(String p) { + switch (p) { + case 'menstruation': return AppColors.coral; + case 'follicular': return AppColors.good; + case 'ovulation': return AppColors.coralDeep; + case 'luteal': return AppColors.warn; + default: return AppColors.inkSoft; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.bg, + body: SafeArea( + bottom: false, + 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), + _topBar(), + const SizedBox(height: Sp.x6), + if (_noApi) + _stateCard('Sign in to track your cycle', + 'Your cycle log syncs with your account. Sign in to log periods and see predictions.') + else if (_loading) + const Padding(padding: EdgeInsets.all(Sp.x8), child: Center(child: CircularProgressIndicator())) + else if (_error != null) + _stateCard("Couldn't load cycle", _error!) + else + ..._content(), + const SizedBox(height: 110), + ], + ), + ), + ), + ); + } + + Widget _topBar() => Row(children: [ + RoundIconButton(Ic.arrowLeft, onTap: () => Navigator.of(context).pop()), + const SizedBox(width: Sp.x3), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Cycle', style: AppText.h1), + const SizedBox(height: 4), + Text('Log periods — see phase, fertile window & body shifts', style: AppText.caption), + ])), + ]); + + List _content() { + final d = _data!; + if (d['enabled'] == false) { + return [_stateCard('Cycle tracking is off', + (d['note'] as String?) ?? 'Enable it in your profile to start tracking.')]; + } + final phase = (d['phase'] as String?) ?? 'unknown'; + final cycleDay = d['cycle_day'] as num?; + final daysUntil = d['days_until_next'] as num?; + final next = d['predicted_next'] as String?; + final fertileStart = d['fertile_start'] as String?; + final fertileEnd = d['fertile_end'] as String?; + final ovulation = d['ovulation_est'] as String?; + final meanLen = d['mean_length'] as num?; + final note = (d['note'] as String?) ?? ''; + final conf = (d['confidence'] as num?) ?? 0; + final logs = (d['logs'] as List?)?.whereType().toList() ?? const []; + final overlay = (d['overlay'] as List?)?.whereType().toList() ?? const []; + final accent = _phaseColor(phase); + + final hasPrediction = next != null && conf > 0; + + return [ + // HERO — current phase + cycle day. + GlowCard( + padding: const EdgeInsets.all(Sp.x6), + child: Row(children: [ + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Row(children: [ + AppIcon(Ic.calendar, size: 16, color: accent), + const SizedBox(width: Sp.x2), + Text('YOUR CYCLE', style: AppText.overline), + ]), + const SizedBox(height: Sp.x3), + Text(_phaseLabel[phase] ?? 'Cycle', style: AppText.h2.copyWith(color: accent)), + const SizedBox(height: Sp.x2), + Text(cycleDay != null ? 'Day ${cycleDay.round()} of your cycle' : 'Log a period to begin', + style: AppText.bodySoft), + ])), + if (cycleDay != null && meanLen != null) + RingStat( + t: (cycleDay / meanLen).clamp(0.0, 1.0), + color: accent, size: 92, stroke: 11, + center: Text('${cycleDay.round()}', style: AppText.metricSm), + ), + ]), + ), + + // PREDICTION. + if (hasPrediction) ...[ + const SizedBox(height: Sp.x6), + SectionHeader('Prediction'), + ProCard(child: Column(children: [ + _row(Ic.droplet, AppColors.coral, 'Next period', + daysUntil != null && daysUntil >= 0 ? 'in ${daysUntil.round()} days' : (next), + sub: next), + if (fertileStart != null && fertileEnd != null) + _row(Ic.heart, AppColors.good, 'Fertile window', '${_md(fertileStart)} – ${_md(fertileEnd)}'), + if (ovulation != null) + _row(Ic.up, AppColors.coralDeep, 'Estimated ovulation', _md(ovulation)), + ])), + const SizedBox(height: Sp.x2), + Text(note, style: AppText.captionMuted), + ], + + // BIOMETRIC OVERLAY — how the body is shifting this cycle (descriptive). + if (overlay.isNotEmpty) ...[ + const SizedBox(height: Sp.x6), + SectionHeader('Body this cycle'), + ProCard(child: Column(children: [ + _overlayRow(Ic.thermometer, AppColors.coralDeep, 'Skin temp vs baseline', overlay, 'skin_temp_idx', 'Δ', signed: true), + _overlayRow(Ic.heart, AppColors.coral, 'Resting HR', overlay, 'resting_hr', 'bpm'), + _overlayRow(Ic.pulse, AppColors.good, 'HRV (RMSSD)', overlay, 'hrv_rmssd', 'ms'), + ])), + const SizedBox(height: Sp.x2), + Text('Skin temp and resting HR often rise, and HRV dips, in the luteal phase. ' + 'Shown for context — the prediction is based on your logged periods, not these.', + style: AppText.captionMuted), + ], + + // LOG actions. + const SizedBox(height: Sp.x6), + SectionHeader('Log a period'), + Row(children: [ + Expanded(child: FilledButton.icon( + onPressed: _logToday, + icon: const AppIcon(Ic.droplet, size: 18, color: Colors.white), + label: const Text('Period started today'), + )), + const SizedBox(width: Sp.x3), + OutlinedButton(onPressed: _pickAndLog, child: const Text('Pick date')), + ]), + + // RECENT LOGS. + if (logs.isNotEmpty) ...[ + const SizedBox(height: Sp.x6), + SectionHeader('Logged periods'), + ProCard(child: Column(children: [ + for (final l in logs.take(12)) + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row(children: [ + AppIcon(Ic.droplet, size: 15, color: AppColors.coral), + const SizedBox(width: Sp.x3), + Expanded(child: Text('${l['date']} · ${l['kind']}', style: AppText.body)), + IconButton( + icon: AppIcon(Ic.cancel, size: 16, color: AppColors.inkMuted), + onPressed: () => _delete('${l['date']}'), + visualDensity: VisualDensity.compact, + ), + ]), + ), + ])), + ], + ]; + } + + // MM-DD short date. + String _md(String iso) => iso.length >= 10 ? iso.substring(5) : iso; + + Widget _row(IconData icon, Color accent, String label, String? value, {String? sub}) => + Padding(padding: const EdgeInsets.symmetric(vertical: Sp.x2), child: Row(children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration(color: accent.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(R.chip)), + child: AppIcon(icon, size: 16, color: accent), + ), + const SizedBox(width: Sp.x3), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(label, style: AppText.label), + if (sub != null) Text(sub, style: AppText.captionMuted), + ])), + Text(value ?? '—', style: AppText.label), + ])); + + // Latest non-null value of [key] across the cycle, with a signed/plain format. + Widget _overlayRow(IconData icon, Color accent, String label, List series, String key, String unit, {bool signed = false}) { + num? latest; + for (final r in series.reversed) { + final v = r[key]; + if (v is num) { latest = v; break; } + } + String val = '—'; + if (latest != null) { + final s = signed ? latest.toStringAsFixed(1) : latest.round().toString(); + val = signed && latest > 0 ? '+$s $unit' : '$s $unit'; + } + return Padding(padding: const EdgeInsets.symmetric(vertical: Sp.x2), child: Row(children: [ + AppIcon(icon, size: 15, color: accent), + const SizedBox(width: Sp.x3), + Expanded(child: Text(label, style: AppText.body)), + Text(val, style: AppText.label), + ])); + } + + Widget _stateCard(String title, String message) => ProCard( + child: Padding(padding: const EdgeInsets.all(Sp.x4), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(title, style: AppText.label), + const SizedBox(height: Sp.x2), + Text(message, style: AppText.captionMuted), + ])), + ); +} diff --git a/lib/ui/heart/live_hr_tile.dart b/lib/ui/heart/live_hr_tile.dart new file mode 100644 index 0000000..a576c3f --- /dev/null +++ b/lib/ui/heart/live_hr_tile.dart @@ -0,0 +1,233 @@ +// LiveHrTile — the ambient "right now" heart-rate tile. Sits as the first card on +// the Heart screen. The number is the live BLE stream (device.liveHr, decoded from +// 0x28/R10 at ~1 Hz); the heart icon beats in a realistic lub-dub at the live BPM, +// with an expanding pulse ring on each beat. +// +// HONESTY GATES (project rule — never fabricate, "—" when absent): +// • liveHr == 0 → OFF-WRIST, never a heart rate → show "—", no beat. +// • stale (no fresh sample within _staleMs) → show "—", no beat. A 1 s ticker +// re-evaluates freshness even when the stream stops pushing (so a frozen value +// decays to "—" honestly). +// • not connected → show "—", no beat. + +import 'dart:math' as math; + +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'; + +const _staleMs = 10000; // a live sample older than this is no longer "live" +const _minBpm = 30, _maxBpm = 220; + +class LiveHrTile extends StatefulWidget { + const LiveHrTile({super.key}); + @override + State createState() => _LiveHrTileState(); +} + +class _LiveHrTileState extends State with TickerProviderStateMixin { + // One beat cycle (lub-dub). Its duration is retuned to match the live BPM. + late final AnimationController _beat = AnimationController( + vsync: this, duration: const Duration(milliseconds: 800)) + ..addStatusListener((s) { + // Re-arm only while we have a live beat to show. + if (s == AnimationStatus.completed && _beating) _beat.forward(from: 0); + }); + + // Freshness ticker — fires every second so a stopped stream decays to "—" + // even though AppState isn't pushing new HR ticks. + late final AnimationController _fresh = AnimationController( + vsync: this, duration: const Duration(seconds: 1)) + ..addStatusListener((s) { + if (s == AnimationStatus.completed) { + if (mounted) setState(() {}); + _fresh.forward(from: 0); + } + }); + + bool _beating = false; + int _lastBpm = 0; + + @override + void initState() { + super.initState(); + _fresh.forward(); + } + + @override + void dispose() { + _beat.dispose(); + _fresh.dispose(); + super.dispose(); + } + + // Retune the beat period to the live BPM and keep it running; stop cleanly + // when there's no live signal. + void _sync(int? bpm, bool live) { + final shouldBeat = live && bpm != null && bpm > 0; + if (shouldBeat) { + final clamped = bpm.clamp(_minBpm, _maxBpm); + if (clamped != _lastBpm) { + _lastBpm = clamped; + _beat.duration = Duration(milliseconds: (60000 / clamped).round()); + } + if (!_beating) { + _beating = true; + _beat.forward(from: 0); + } + } else if (_beating) { + _beating = false; + _beat.stop(); + _beat.value = 0; + } + } + + @override + Widget build(BuildContext context) { + // Rebuild only when the live-HR snapshot actually changes (not on every + // unrelated AppState notify). Record → structural equality. + final snap = context.select((s) { + final d = s.device; + return (d.liveHr, d.liveHrAt, d.connection); + }); + final (hr, at, conn) = snap; + + final connected = conn == 'connected' || conn == 'syncing'; + final nowMs = DateTime.now().millisecondsSinceEpoch; + final fresh = at != null && (nowMs - at) < _staleMs; + final offWrist = hr == 0; // 0 is OFF-WRIST, never a heart rate + final live = connected && fresh && hr != null && hr > 0; + + // Drive the animation off the resolved state (post-frame so we don't + // mutate controllers during build). + WidgetsBinding.instance.addPostFrameCallback((_) => _sync(hr, live)); + + final accent = AppColors.coral; + final subtitle = live + ? 'beats per minute' + : !connected + ? 'strap not connected' + : offWrist + ? 'strap off wrist' + : 'no live signal'; + + return GlowCard( + glow: accent, + padding: const EdgeInsets.all(Sp.x6), + child: Row( + children: [ + _BeatingHeart(beat: _beat, accent: accent, live: live), + const SizedBox(width: Sp.x5), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + _LiveDot(beat: _beat, accent: accent, live: live), + const SizedBox(width: Sp.x2), + Text('LIVE HEART RATE', style: AppText.overline), + ]), + const SizedBox(height: Sp.x3), + Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ + Text(live ? '$hr' : '—', style: AppText.display.copyWith(color: accent)), + if (live) ...[ + const SizedBox(width: Sp.x2), + Padding( + padding: const EdgeInsets.only(bottom: 7), + child: Text('bpm', style: AppText.bodySoft), + ), + ], + ]), + const SizedBox(height: 2), + Text(subtitle, style: AppText.captionMuted), + ], + ), + ), + ], + ), + ); + } +} + +/// The icon that beats. A realistic lub-dub scale curve + an expanding ring that +/// blooms outward and fades on each cycle. +class _BeatingHeart extends StatelessWidget { + final AnimationController beat; + final Color accent; + final bool live; + const _BeatingHeart({required this.beat, required this.accent, required this.live}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 64, + height: 64, + child: AnimatedBuilder( + animation: beat, + builder: (context, child) { + final t = beat.value; + // lub-dub: sharp first contraction, small relax, softer second beat, + // then settle. Two raised-cosine pulses. + double pulse(double center, double width, double amp) { + final x = ((t - center) / width).clamp(-1.0, 1.0); + return amp * 0.5 * (1 + (x.abs() >= 1 ? -1.0 : math.cos(math.pi * x))); + } + final s = live ? 1.0 + pulse(0.10, 0.14, 0.22) + pulse(0.30, 0.12, 0.11) : 1.0; + // Ring blooms only on the first (loud) beat. + final ringT = (t / 0.55).clamp(0.0, 1.0); + final ringScale = 1.0 + ringT * 0.9; + final ringAlpha = live ? (1 - ringT) * 0.35 : 0.0; + return Stack( + alignment: Alignment.center, + children: [ + if (ringAlpha > 0.01) + Transform.scale( + scale: ringScale, + child: Container( + width: 44, height: 44, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: accent.withValues(alpha: ringAlpha), width: 2), + ), + ), + ), + Transform.scale( + scale: s, + child: AppIcon(Ic.heart, size: 38, + color: live ? accent : AppColors.inkMuted), + ), + ], + ); + }, + ), + ); + } +} + +/// Small pulsing "live" dot next to the overline — fades in sync with each beat. +class _LiveDot extends StatelessWidget { + final AnimationController beat; + final Color accent; + final bool live; + const _LiveDot({required this.beat, required this.accent, required this.live}); + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: beat, + builder: (context, _) { + final a = live ? (0.45 + 0.55 * (1 - beat.value).clamp(0.0, 1.0)) : 0.25; + return Container( + width: 8, height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: (live ? accent : AppColors.inkMuted).withValues(alpha: a), + ), + ); + }, + ); + } +} 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 6d90f6a..cac2ea2 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(), ]; @@ -478,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( @@ -548,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', @@ -571,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), @@ -582,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: @@ -598,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 3fca806..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( @@ -124,22 +125,39 @@ 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 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, required this.labels, - this.color = AppColors.coral, + this.color, 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 color = this.color ?? AppColors.coral; final maxV = values.isEmpty ? 1.0 : math.max(1.0, values.reduce(math.max)); return SizedBox( height: height, @@ -148,34 +166,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), + ], + ), ), ), ), @@ -188,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, @@ -244,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) { @@ -335,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; @@ -348,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( @@ -448,3 +483,92 @@ 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, this.cell = 16}); + @override + Widget build(BuildContext context) { + final color = this.color ?? AppColors.good; + if (days.isEmpty) return const SizedBox.shrink(); + const wd = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; + // Pad the front so the first day lands on its weekday column (Mon=0). + final first = days.first.date; + final lead = (first.weekday + 6) % 7; // Mon=0 + final cells = <({DateTime? date, double? t})>[ + for (int i = 0; i < lead; i++) (date: null, t: null), + for (final d in days) (date: d.date, t: d.t), + ]; + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + for (final l in wd) + SizedBox(width: cell + 4, child: Text(l, textAlign: TextAlign.center, style: AppText.captionMuted)), + ]), + const SizedBox(height: 4), + Wrap(spacing: 4, runSpacing: 4, children: [ + for (final c in cells) + Container( + width: cell, height: cell, + decoration: BoxDecoration( + color: c.date == null + ? Colors.transparent + : (c.t == null ? AppColors.surfaceSunk : color.withValues(alpha: (0.18 + 0.82 * c.t!.clamp(0, 1)))), + borderRadius: BorderRadius.circular(4), + ), + ), + ]), + ]); + } +} diff --git a/lib/ui/kit/kit.dart b/lib/ui/kit/kit.dart index 911a52d..f117336 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'; @@ -57,6 +60,15 @@ class Ic { static const droplet = HugeIcons.strokeRoundedDroplet; static const run = HugeIcons.strokeRoundedRunningShoes; static const bell = HugeIcons.strokeRoundedNotification03; + static const thermometer = HugeIcons.strokeRoundedTemperature; + static const ai = HugeIcons.strokeRoundedAiMagic; + static const plus = HugeIcons.strokeRoundedAdd01; + static const history = HugeIcons.strokeRoundedClock04; + static const trash = HugeIcons.strokeRoundedDelete02; + static const github = HugeIcons.strokeRoundedGithub; + static const discord = HugeIcons.strokeRoundedDiscord; + static const reddit = HugeIcons.strokeRoundedReddit; + static const twitter = HugeIcons.strokeRoundedNewTwitter; } /// White rounded card with soft warm shadow. The base surface for everything. @@ -64,25 +76,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 +124,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 +149,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 +210,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 +255,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, )), ), @@ -275,6 +304,90 @@ 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), + ), + ); + } +} + +/// 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; @@ -294,41 +407,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( @@ -336,7 +461,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), ), ), ); @@ -374,7 +499,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), ], ]), ), @@ -385,3 +510,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: [ + 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/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/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/notification_relay_section.dart b/lib/ui/profile/notification_relay_section.dart new file mode 100644 index 0000000..b16bfe3 --- /dev/null +++ b/lib/ui/profile/notification_relay_section.dart @@ -0,0 +1,308 @@ +// Notification-relay settings — pick which phone apps buzz the strap. ANDROID ONLY: +// both the card and the screen render nothing unless NotificationRelay.supported, so +// the feature simply doesn't exist on iOS (no "unavailable" copy, no trace). + +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:installed_apps/app_info.dart'; +import 'package:installed_apps/installed_apps.dart'; +import 'package:provider/provider.dart'; + +import '../../notify/notification_relay.dart'; +import '../../state/app_state.dart'; +import '../../theme/theme.dart'; +import '../../theme/theme_switcher.dart'; +import '../../theme/tokens.dart'; +import '../kit/kit.dart'; + +/// The Profile card. Returns an empty box on unsupported platforms (iOS) so the +/// caller can place it unconditionally and it stays invisible there. +class NotificationRelaySection extends StatelessWidget { + const NotificationRelaySection({super.key}); + + @override + Widget build(BuildContext context) { + final relay = context.read().notificationRelay; + if (!relay.supported) return const SizedBox.shrink(); + return AnimatedBuilder( + animation: relay, + builder: (context, _) { + final subtitle = !relay.enabled + ? 'Off' + : !relay.permissionGranted + ? 'Needs notification access' + : relay.appCount == 0 + ? 'On · no apps selected' + : 'On · ${relay.appCount} app${relay.appCount == 1 ? '' : 's'}'; + return ProCard( + onTap: () => Navigator.of(context).push( + themedRoute((_) => const NotificationRelayScreen())), + child: Row( + children: [ + Icon(Ic.bell, size: 22, color: AppColors.coral), + const SizedBox(width: Sp.x4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Buzz on app notifications', style: AppText.title), + const SizedBox(height: 2), + Text(subtitle, style: AppText.bodySoft), + ], + ), + ), + Icon(Ic.arrowRight, size: 18, color: AppColors.inkMuted), + ], + ), + ); + }, + ); + } +} + +/// Full settings screen: master toggle → grant access → per-app allow-list. +class NotificationRelayScreen extends StatefulWidget { + const NotificationRelayScreen({super.key}); + @override + State createState() => _NotificationRelayScreenState(); +} + +class _NotificationRelayScreenState extends State + with WidgetsBindingObserver { + late Future> _apps; + String _query = ''; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _apps = InstalledApps.getInstalledApps( + excludeSystemApps: true, withIcon: true); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // Coming back from the system Settings page → re-check the grant. + if (state == AppLifecycleState.resumed && mounted) { + context.read().notificationRelay.refreshPermission(); + } + } + + @override + Widget build(BuildContext context) { + final relay = context.read().notificationRelay; + return Scaffold( + backgroundColor: AppColors.bg, + body: SafeArea( + bottom: false, + child: AnimatedBuilder( + animation: relay, + builder: (context, _) { + final showApps = relay.enabled && relay.permissionGranted; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(Sp.screen, Sp.x4, Sp.screen, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + RoundIconButton(Ic.arrowLeft, + onTap: () => Navigator.of(context).maybePop()), + const SizedBox(width: Sp.x3), + Expanded(child: Text('Band notifications', style: AppText.h1)), + ]), + const SizedBox(height: Sp.x4), + Text( + 'When an app below posts a notification, your strap gives a ' + 'short buzz. Your phone must be connected to the band.', + style: AppText.bodySoft, + ), + const SizedBox(height: Sp.x5), + _MasterToggle(relay: relay), + if (relay.enabled && !relay.permissionGranted) ...[ + const SizedBox(height: Sp.x4), + _GrantCard(relay: relay), + ], + const SizedBox(height: Sp.x5), + if (showApps) ...[ + Text('APPS', style: AppText.overline), + const SizedBox(height: Sp.x3), + _SearchField(onChanged: (q) => setState(() => _query = q)), + const SizedBox(height: Sp.x3), + ], + ], + ), + ), + if (showApps) + Expanded(child: _AppList(relay: relay, future: _apps, query: _query)) + else + const SizedBox.shrink(), + const SizedBox(height: Sp.x4), + ], + ); + }, + ), + ), + ); + } +} + +class _MasterToggle extends StatelessWidget { + final NotificationRelay relay; + const _MasterToggle({required this.relay}); + @override + Widget build(BuildContext context) { + return ProCard( + child: Row( + children: [ + Icon(Ic.bell, size: 22, color: AppColors.coral), + const SizedBox(width: Sp.x4), + Expanded(child: Text('Relay notifications', style: AppText.title)), + Switch.adaptive( + value: relay.enabled, + activeTrackColor: AppColors.coral, + onChanged: (v) => relay.setEnabled(v), + ), + ], + ), + ); + } +} + +class _GrantCard extends StatelessWidget { + final NotificationRelay relay; + const _GrantCard({required this.relay}); + @override + Widget build(BuildContext context) { + return ProCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Notification access needed', style: AppText.title), + const SizedBox(height: 4), + Text( + 'Android needs your permission to read which app posted a notification. ' + 'We only use it to decide whether to buzz the band — nothing leaves your phone.', + style: AppText.caption, + ), + const SizedBox(height: Sp.x4), + Align( + alignment: Alignment.centerLeft, + child: FilledButton( + onPressed: () => relay.requestPermission(), + child: const Text('Grant access'), + ), + ), + ], + ), + ); + } +} + +class _SearchField extends StatelessWidget { + final ValueChanged onChanged; + const _SearchField({required this.onChanged}); + @override + Widget build(BuildContext context) { + return TextField( + onChanged: onChanged, + style: AppText.body, + decoration: InputDecoration( + hintText: 'Search apps', + hintStyle: AppText.bodySoft, + filled: true, + fillColor: AppColors.surfaceAlt, + contentPadding: const EdgeInsets.symmetric(horizontal: Sp.x4, vertical: Sp.x3), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(R.chip), + borderSide: BorderSide.none, + ), + ), + ); + } +} + +class _AppList extends StatelessWidget { + final NotificationRelay relay; + final Future> future; + final String query; + const _AppList({required this.relay, required this.future, required this.query}); + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return Center( + child: SizedBox( + width: 22, height: 22, + child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.coral)), + ); + } + final all = snap.data ?? const []; + final q = query.trim().toLowerCase(); + final apps = (q.isEmpty + ? all + : all.where((a) => a.name.toLowerCase().contains(q)).toList()) + ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + if (apps.isEmpty) { + return Center(child: Text('No apps found', style: AppText.captionMuted)); + } + return ListView.separated( + padding: const EdgeInsets.fromLTRB(Sp.screen, 0, Sp.screen, Sp.x8), + itemCount: apps.length, + separatorBuilder: (_, _) => const SizedBox(height: Sp.x2), + itemBuilder: (context, i) { + final a = apps[i]; + return _AppRow(relay: relay, app: a); + }, + ); + }, + ); + } +} + +class _AppRow extends StatelessWidget { + final NotificationRelay relay; + final AppInfo app; + const _AppRow({required this.relay, required this.app}); + @override + Widget build(BuildContext context) { + final Uint8List? icon = app.icon; + return ProCard( + padding: const EdgeInsets.symmetric(horizontal: Sp.x4, vertical: Sp.x3), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: icon != null && icon.isNotEmpty + ? Image.memory(icon, width: 34, height: 34, gaplessPlayback: true) + : Container( + width: 34, height: 34, color: AppColors.surfaceAlt, + child: Icon(Ic.bell, size: 18, color: AppColors.inkMuted)), + ), + const SizedBox(width: Sp.x4), + Expanded( + child: Text(app.name, style: AppText.body, maxLines: 1, + overflow: TextOverflow.ellipsis), + ), + Switch.adaptive( + value: relay.isAppEnabled(app.packageName), + activeTrackColor: AppColors.coral, + onChanged: (v) => relay.setAppEnabled(app.packageName, v), + ), + ], + ), + ); + } +} diff --git a/lib/ui/profile/profile_screen.dart b/lib/ui/profile/profile_screen.dart index 5037178..9d5dae0 100644 --- a/lib/ui/profile/profile_screen.dart +++ b/lib/ui/profile/profile_screen.dart @@ -3,18 +3,43 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../../state/app_state.dart'; +import '../../state/units_controller.dart'; 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'; class ProfileScreen extends StatelessWidget { const ProfileScreen({super.key}); + // Community links. Editable here; swap any URL and rebuild — no backend needed. + static const List<({String label, IconData icon, String url})> _socials = [ + (label: 'GitHub', icon: Ic.github, url: 'https://github.com/OpenStrap'), + (label: 'Discord', icon: Ic.discord, url: 'https://discord.gg/dUXds5MWkd'), + (label: 'Reddit', icon: Ic.reddit, url: 'https://reddit.com/r/openstrap'), + (label: 'X', icon: Ic.twitter, url: 'https://x.com/OpenStrap'), + ]; + + static Future _openUrl(String url) async { + // Don't gate on canLaunchUrl — it false-negatives on Android 11+ without a + // manifest entry. Just launch and swallow failures. + try { + await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + } catch (_) {} + } + @override Widget build(BuildContext context) { final app = context.watch(); + final units = context.watch(); final user = app.user ?? const {}; final name = (user['name'] ?? '').toString().trim(); final email = (user['email'] ?? '').toString().trim(); @@ -69,7 +94,7 @@ class ProfileScreen extends StatelessWidget { icon: Ic.activity, label: 'Height', value: user['height_cm'] != null - ? '${_num(user['height_cm'])} cm' + ? units.height(user['height_cm'] as num?) : 'Add', onTap: () => _editProfileSheet(context, app), ), @@ -78,7 +103,7 @@ class ProfileScreen extends StatelessWidget { icon: Ic.fire, label: 'Weight', value: user['weight_kg'] != null - ? '${_num(user['weight_kg'])} kg' + ? units.weight(user['weight_kg'] as num?) : 'Add', onTap: () => _editProfileSheet(context, app), ), @@ -99,6 +124,137 @@ 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( + themedRoute((_) => StepGoalScreen( + goal: (user['step_goal'] as num?)?.toInt(), + ), + ), + ), + ), + ), + + 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( + padding: const EdgeInsets.all(Sp.x3), + child: Row( + children: [ + for (final s in UnitSystem.values) + Expanded( + child: GestureDetector( + onTap: () => units.setSystem(s), + child: Container( + margin: EdgeInsets.only(right: s == UnitSystem.metric ? Sp.x2 : 0), + padding: const EdgeInsets.symmetric(vertical: Sp.x3), + decoration: BoxDecoration( + color: units.system == s ? AppColors.coralSoft : AppColors.surfaceSunk, + borderRadius: BorderRadius.circular(R.chip), + ), + child: Column( + children: [ + Text(s.label, + textAlign: TextAlign.center, + style: AppText.label.copyWith( + color: units.system == s ? AppColors.coralInk : AppColors.inkSoft)), + const SizedBox(height: 2), + Text(s == UnitSystem.metric ? 'kg · cm' : 'lb · ft/in', + textAlign: TextAlign.center, style: AppText.captionMuted), + ], + ), + ), + ), + ), + ], + ), + ), + + const SizedBox(height: Sp.x7), + + // ── Cycle tracking (explicit opt-in) ───────────────────────── + const SectionHeader('Cycle tracking'), + ProCard( + padding: const EdgeInsets.symmetric(horizontal: Sp.x5, vertical: Sp.x3), + child: Row( + children: [ + AppIcon(Ic.calendar, size: 18, color: AppColors.coral), + const SizedBox(width: Sp.x4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Track menstrual cycle', style: AppText.title), + const SizedBox(height: 2), + Text('Log periods and see phase, next period & fertile window. ' + 'Off by default — nothing is computed until you turn this on.', + style: AppText.captionMuted), + ], + ), + ), + const SizedBox(width: Sp.x3), + Switch( + value: user['track_cycle'] == true || user['track_cycle'] == 1, + onChanged: (v) async { + try { + await app.updateProfile({'track_cycle': v ? 1 : 0}); + } catch (_) {} + }, + ), + ], + ), + ), + + const SizedBox(height: Sp.x7), + + // ── Appearance ─────────────────────────────────────────────── + const SectionHeader('Appearance'), + ProCard( + child: const AppearanceSelector(labeled: true), + ), + + const SizedBox(height: Sp.x7), + + // ── Gestures ───────────────────────────────────────────────── + const SectionHeader('Gestures'), + const GestureSettingsCard(), + + const SizedBox(height: Sp.x7), + + // ── Notifications (Android only — section self-hides on iOS) ── + if (app.notificationRelay.supported) ...[ + const SectionHeader('Notifications'), + const NotificationRelaySection(), + const SizedBox(height: Sp.x7), + ], + // ── Backend ────────────────────────────────────────────────── const SectionHeader('Backend'), ProCard( @@ -112,7 +268,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), @@ -142,6 +298,40 @@ class ProfileScreen extends StatelessWidget { const SizedBox(height: Sp.x7), + // ── Community ──────────────────────────────────────────────── + const SectionHeader('Community'), + ProCard( + child: Row(children: [ + for (final s in _socials) + Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(R.card), + onTap: () => _openUrl(s.url), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Sp.x3), + child: Column(children: [ + Container( + padding: const EdgeInsets.all(11), + decoration: BoxDecoration( + color: AppColors.surfaceSunk, shape: BoxShape.circle), + child: AppIcon(s.icon, size: 20, color: AppColors.ink), + ), + const SizedBox(height: Sp.x2), + Text(s.label, style: AppText.caption), + ]), + ), + ), + ), + ]), + ), + Padding( + padding: const EdgeInsets.only(top: Sp.x2, left: Sp.x2), + child: Text('Join the community, report bugs, or peek at the source.', + style: AppText.captionMuted), + ), + + const SizedBox(height: Sp.x7), + // ── Account ────────────────────────────────────────────────── const SectionHeader('Account'), ProCard( @@ -152,7 +342,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), ), ), @@ -167,7 +357,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), ]), @@ -197,12 +387,6 @@ class ProfileScreen extends StatelessWidget { return s[0].toUpperCase() + s.substring(1); } - static String _num(Object? v) { - final n = (v is num) ? v : num.tryParse('$v'); - if (n == null) return '$v'; - return n == n.roundToDouble() ? '${n.round()}' : '$n'; - } - // ── Profile edit sheet ──────────────────────────────────────────────── Future _editProfileSheet(BuildContext context, AppState app) async { await showModalBottomSheet( @@ -305,7 +489,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)), @@ -479,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', @@ -494,7 +693,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'), ), ), @@ -544,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( @@ -596,6 +804,7 @@ class _ProfileEditSheetState extends State<_ProfileEditSheet> { late final TextEditingController _age; late final TextEditingController _height; late final TextEditingController _weight; + late final UnitsController _units; String? _sex; bool _saving = false; @@ -603,13 +812,13 @@ class _ProfileEditSheetState extends State<_ProfileEditSheet> { void initState() { super.initState(); final u = widget.app.user ?? const {}; + _units = context.read(); _name = TextEditingController(text: (u['name'] ?? '').toString()); _age = TextEditingController( text: u['age'] != null ? '${u['age']}' : ''); - _height = TextEditingController( - text: u['height_cm'] != null ? '${u['height_cm']}' : ''); - _weight = TextEditingController( - text: u['weight_kg'] != null ? '${u['weight_kg']}' : ''); + // Pre-fill height/weight in the user's chosen units (stored as cm/kg). + _height = TextEditingController(text: _units.heightField(u['height_cm'] as num?)); + _weight = TextEditingController(text: _units.weightField(u['weight_kg'] as num?)); _sex = u['sex']?.toString(); } @@ -628,8 +837,9 @@ class _ProfileEditSheetState extends State<_ProfileEditSheet> { await widget.app.updateProfile({ 'name': _name.text.trim().isEmpty ? null : _name.text.trim(), 'age': int.tryParse(_age.text), - 'height_cm': double.tryParse(_height.text), - 'weight_kg': double.tryParse(_weight.text), + // Convert from the user's display units back to metric for storage. + 'height_cm': _units.heightToCm(_height.text), + 'weight_kg': _units.weightToKg(_weight.text), if (_sex != null) 'sex': _sex, }); if (mounted) { @@ -668,7 +878,7 @@ class _ProfileEditSheetState extends State<_ProfileEditSheet> { child: TextField( controller: _height, keyboardType: TextInputType.number, - decoration: const InputDecoration(labelText: 'Height (cm)'), + decoration: InputDecoration(labelText: _units.heightLabel), ), ), const SizedBox(width: Sp.x3), @@ -676,7 +886,7 @@ class _ProfileEditSheetState extends State<_ProfileEditSheet> { child: TextField( controller: _weight, keyboardType: TextInputType.number, - decoration: const InputDecoration(labelText: 'Weight (kg)'), + decoration: InputDecoration(labelText: _units.weightLabel), ), ), ]), @@ -765,7 +975,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)), ]), @@ -776,7 +986,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..42fb167 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', @@ -418,6 +418,14 @@ class _RecapScreenState extends State { Future _share() async { setState(() => _sharing = true); try { + // iOS/iPad: the share sheet is a popover and REQUIRES an anchor rect, or + // it throws PlatformException(sharePositionOrigin: argument must be set). + // Capture it now, before any async gap, while layout is stable. + final box = context.findRenderObject() as RenderBox?; + final origin = (box != null && box.hasSize) + ? (box.localToGlobal(Offset.zero) & box.size) + : null; + final boundary = _cardKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; if (boundary == null) throw StateError('Card not ready'); @@ -435,6 +443,7 @@ class _RecapScreenState extends State { await Share.shareXFiles( [XFile(file.path)], text: 'My OpenStrap recap', + sharePositionOrigin: origin, ); } catch (e) { if (!mounted) return; @@ -448,8 +457,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 +474,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/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/screens/detail_cards.dart b/lib/ui/screens/detail_cards.dart new file mode 100644 index 0000000..5d572cd --- /dev/null +++ b/lib/ui/screens/detail_cards.dart @@ -0,0 +1,662 @@ +// 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 metric 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'; +import 'trend_screen.dart'; + +String hm(num? minutes) { + if (minutes == null) return '—'; + final m = minutes.round(); + return '${m ~/ 60}h ${m % 60}m'; +} + +/// Signed deviation string for relative metrics ("+0.3", "-0.2", "0.0"). +String _signed(num? v) { + if (v == null) return '—'; + final s = v.toStringAsFixed(1); + return v > 0 ? '+$s' : s; +} + +/// 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 metrics. +final _zoneColors = [AppColors.cool, AppColors.loadDetraining, AppColors.good, AppColors.warn, AppColors.coral]; + +// ── 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), 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)), + ]), + ), + + // 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: [ + 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), + ])), + ] 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. + if (hrv != null) ...[ + const SizedBox(height: Sp.x6), + SectionHeader('Heart-rate variability'), + MetricGroup([ + 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) + 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) + 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.', + 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) + 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.', + value: '${noct['vs_baseline_bpm']}', unit: 'bpm'), + ]), + ], + + if (resp != null || spo2 != null || dmap['desaturation'] is Map) ...[ + 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: 'Δ'), + // Overnight desaturation screen (RELATIVE, not diagnostic) — clustered dips + // in the red/IR ratio vs your baseline. An apnea-style screening signal only. + if (dmap['desaturation'] is Map) + MetricRow(icon: Ic.droplet, accent: AppColors.warn, label: 'Desaturation dips', + info: 'Number of relative blood-oxygen dips overnight (per hour). A screen, not a diagnosis — talk to a clinician if it stays high.', + value: '${(dmap['desaturation'] as Map)['events'] ?? 0}', + unit: '· ${(dmap['desaturation'] as Map)['odi'] ?? 0}/h'), + ]), + ], + + // Skin temperature — relative deviation vs your personal baseline (°), + // tappable into its trend. Honest: relative, not an absolute thermometer. + if (spo2 != null || d['skin_temp'] is Map) ...[ + const SizedBox(height: Sp.x6), + SectionHeader('Skin temperature'), + MetricGroup([ + if (d['skin_temp'] is Map) + TrendMetricRow(icon: Ic.thermometer, accent: AppColors.coralDeep, label: 'Skin temp vs baseline', + info: infoFor('skin_temp'), + value: _signed(_n((d['skin_temp'] as Map)['value'])), unit: 'Δ', + metric: 'skin_temp', trendTitle: 'Skin temp vs baseline') + else + MetricRow(icon: Ic.thermometer, accent: AppColors.inkSoft, label: 'Skin temp vs baseline', + info: infoFor('skin_temp'), value: '—'), + ]), + ], + + // 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('Illness watch'), + _IllnessCard(illness), + ], + + // Irregular-rhythm screen (Poincaré from nocturnal RR) — ALWAYS shown, + // with a "building" state until there's a night of RR to read. + ...[ + const SizedBox(height: Sp.x6), + SectionHeader('Irregular-beat watch'), + _IrregularCard(d['irregular'] is Map ? d['irregular'] as Map : null), + ], + + // 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() ?? ''), + ])), + ], + ]); + }, + ); + } +} + +// 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: 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) ...[ + 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() ?? ''), + ], + )), + ], + ])); + } +} + +// Irregular-rhythm screen card — ALWAYS shown, three honest states: +// "building baseline" (no data yet), green "looks regular", amber "irregular". +// 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 conf = (irr?['confidence'] as num?) ?? 0; + // No usable nocturnal RR yet → honest "building" state. + if (irr == null || conf <= 0) { + 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: AppIcon(Ic.info, size: 17, color: AppColors.inkMuted), + ), + const SizedBox(width: Sp.x3), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Listening for your rhythm', style: AppText.label), + const SizedBox(height: 2), + Text('This screens your beat-to-beat (RR) timing overnight for irregularity. ' + 'It needs a night of good wear with heart-rate variability data to read.', + style: AppText.captionMuted), + ])), + ]))); + } + 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) ...[ + 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. +// 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: [ + AppIcon(Ic.watch, size: 16, color: AppColors.coralDeep), + const SizedBox(width: Sp.x2), + Text('TIME WORN', style: AppText.overline), + const SizedBox(width: Sp.x2), + 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 — 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: [ + 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. +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', 'recovery'], + 'body': ['strain'], +}; + +class SectionExtras extends StatefulWidget { + final String section; // 'sleep' | 'heart' | 'body' + const SectionExtras({super.key, required this.section}); + @override + State createState() => _SectionExtrasState(); +} + +class _SectionExtrasState 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.section] ?? 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 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() ?? ''; + 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)), + ], + ]); + } +} + diff --git a/lib/ui/screens/metric_row.dart b/lib/ui/screens/metric_row.dart new file mode 100644 index 0000000..61c7b49 --- /dev/null +++ b/lib/ui/screens/metric_row.dart @@ -0,0 +1,146 @@ +// 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. + +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.', + 'skin_temp': 'Skin temperature vs your personal overnight baseline. Relative (Δ), not an absolute thermometer.', + '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.', + '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]; + +/// 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, + 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( + 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), + 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(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 new file mode 100644 index 0000000..b5b95ce --- /dev/null +++ b/lib/ui/screens/metric_screen.dart @@ -0,0 +1,303 @@ +// 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 metric'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 MetricScreen 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; + final Widget? action; // optional top-right action (e.g. AI coach button) + const MetricScreen({ + super.key, + required this.title, + required this.metric, + required this.icon, + required this.accent, + required this.todayDetail, + required this.dayDetail, + this.valueFmt, + this.action, + }); + + @override + State createState() => _MetricScreenState(); +} + +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) { + final scale = _tab == 1 ? 'week' : _tab == 2 ? 'month' : 'quarter'; + return Scaffold( + // 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( + bottom: false, + child: RefreshIndicator( + onRefresh: _onRefresh, + color: widget.accent, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + 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()) ...[ + RoundIconButton(Ic.arrowLeft, onTap: () => Navigator.of(context).maybePop()), + const SizedBox(width: Sp.x3), + ], + Expanded(child: Text(widget.title, style: AppText.h1)), + if (widget.action != null) widget.action!, + ]), + 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) + KeyedSubtree(key: ValueKey('today-$_refresh'), child: widget.todayDetail(context)) + else + _DrillLevel( + key: ValueKey('$scale-$_refresh-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 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)))), + ); + } + 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' && widget.metric != 'wear') ...[ + 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 + 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'; + } + return v == v.roundToDouble() ? v.toStringAsFixed(0) : v.toStringAsFixed(1); + } +} diff --git a/lib/ui/screens/screens.dart b/lib/ui/screens/screens.dart new file mode 100644 index 0000000..642eb25 --- /dev/null +++ b/lib/ui/screens/screens.dart @@ -0,0 +1,176 @@ +// 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 'package:provider/provider.dart'; +import '../../state/app_state.dart'; +import '../../theme/theme.dart'; +import '../../theme/tokens.dart'; +import '../kit/kit.dart'; +import '../sleep/sleep_detail_screen.dart'; +import '../activity/strain_detail_screen.dart'; +import '../heart/live_hr_tile.dart'; +import '../cycle/cycle_screen.dart'; +import '../spotcheck/spot_check_screen.dart'; +import '../coach/ai_coach_screen.dart'; +import 'metric_screen.dart'; +import 'detail_cards.dart'; + +String todayUtc() => DateTime.now().toUtc().toIso8601String().substring(0, 10); + + +class SleepScreen extends StatelessWidget { + const SleepScreen({super.key}); + @override + Widget build(BuildContext context) => MetricScreen( + 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 SectionExtras(section: 'sleep'), + ]), + dayDetail: (ctx, date) => SleepDetailScreen(date: date, embedded: true), + ); +} + +class HeartScreen extends StatelessWidget { + const HeartScreen({super.key}); + @override + 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: [ + const LiveHrTile(), + const SizedBox(height: Sp.x4), + const SpotCheckEntryCard(), + const SizedBox(height: Sp.x4), + HeartDayCard(date: todayUtc()), + const SectionExtras(section: 'heart'), + ]), + dayDetail: (ctx, date) => HeartDayCard(date: date), + ); +} + +/// 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.) +class BodyScreen extends StatelessWidget { + const BodyScreen({super.key}); + @override + Widget build(BuildContext context) => MetricScreen( + title: 'Body', + metric: 'strain', + icon: Ic.strain, + accent: AppColors.coral, + action: Builder(builder: (ctx) => GestureDetector( + onTap: () => Navigator.of(ctx).push( + MaterialPageRoute(builder: (_) => const AiCoachScreen())), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: Sp.x3, vertical: 8), + decoration: BoxDecoration( + color: AppColors.coral, borderRadius: BorderRadius.circular(R.pill)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + const AppIcon(Ic.ai, size: 16, color: Colors.white), + const SizedBox(width: 6), + Text('AI Coach', style: AppText.label.copyWith(color: Colors.white)), + ]), + ), + )), + todayDetail: (ctx) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + StrainDetailScreen(date: todayUtc(), embedded: true), + const CycleEntryCard(), + const SectionExtras(section: 'body'), + ]), + dayDetail: (ctx, date) => StrainDetailScreen(date: date, embedded: true), + ); +} + +/// Tappable entry to the live HRV spot-check. +class SpotCheckEntryCard extends StatelessWidget { + const SpotCheckEntryCard({super.key}); + @override + Widget build(BuildContext context) { + return ProCard( + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const SpotCheckScreen())), + child: Row(children: [ + Container( + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: AppColors.good.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(R.chip)), + child: AppIcon(Ic.pulse, size: 18, color: AppColors.good), + ), + const SizedBox(width: Sp.x3), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Live HRV spot check', style: AppText.label), + const SizedBox(height: 2), + Text('A quick 60-second reading, any time', style: AppText.captionMuted), + ])), + AppIcon(Ic.arrowRight, size: 18, color: AppColors.inkMuted), + ]), + ); + } +} + +/// Tappable entry to the Cycle screen — shown only when the user has explicitly +/// opted in (Profile → Track menstrual cycle). Consent-gated, never inferred. +class CycleEntryCard extends StatelessWidget { + const CycleEntryCard({super.key}); + @override + Widget build(BuildContext context) { + final track = context.select((s) { + final v = s.user?['track_cycle']; + return v == true || v == 1; + }); + if (!track) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: Sp.x6), + child: ProCard( + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const CycleScreen())), + child: Row(children: [ + Container( + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: AppColors.coral.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(R.chip)), + child: AppIcon(Ic.calendar, size: 18, color: AppColors.coral), + ), + const SizedBox(width: Sp.x3), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Cycle tracking', style: AppText.label), + const SizedBox(height: 2), + Text('Phase, next period & fertile window', style: AppText.captionMuted), + ])), + AppIcon(Ic.arrowRight, size: 18, color: AppColors.inkMuted), + ]), + ), + ); + } +} diff --git a/lib/ui/screens/trend_screen.dart b/lib/ui/screens/trend_screen.dart new file mode 100644 index 0000000..3203245 --- /dev/null +++ b/lib/ui/screens/trend_screen.dart @@ -0,0 +1,170 @@ +// 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/theme_switcher.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, + String Function(double v)? valueFmt, +}) { + Navigator.of(context).push(themedRoute((_) => 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, + this.valueFmt, + }); + + @override + 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. +/// 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, + 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), + ); +} diff --git a/lib/ui/sleep/sleep_detail_screen.dart b/lib/ui/sleep/sleep_detail_screen.dart index 4821544..728fa14 100644 --- a/lib/ui/sleep/sleep_detail_screen.dart +++ b/lib/ui/sleep/sleep_detail_screen.dart @@ -7,13 +7,21 @@ 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'; +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' - 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 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}); /// Convenience: a detail screen for today (local). factory SleepDetailScreen.today({Key? key}) { @@ -109,6 +117,29 @@ class _SleepDetailScreenState extends State { num? get _remMin => _num(_stages['rem_min']); num? get _lightMin => _num(_stages['light_min']); + // Sleep cycles (ultradian NREM↔REM, fractal-cycle method on HRV). Beta. + List> get _cycles { + final raw = _data['cycles']; + if (raw is! List) return const []; + return raw.map((e) => _map(e)).where((m) => m.isNotEmpty).toList(); + } + + num? get _cyclesMean => _num(_data['cycles_mean_min']); + + List> get _cycleSeries { + final raw = _data['cycle_series']; + if (raw is! List) return const []; + final out = >[]; + for (final p in raw) { + final m = _map(p); + final t = _num(m['t'])?.toInt(); + final z = _num(m['z'])?.toDouble(); + if (t != null && z != null) out.add(MapEntry(t, z)); + } + out.sort((a, b) => a.key.compareTo(b.key)); + return out; + } + Map get _nocturnal => _map(_data['nocturnal']); Map get _resp => _map(_data['resp']); bool get _hasNocturnal => _num(_nocturnal['sleeping_hr_avg']) != null; @@ -226,57 +257,99 @@ class _SleepDetailScreenState extends State { // ── build ────────────────────────────────────────────────────────────────── + /// The night's sections (phase-aware). Shared by the standalone screen and the + /// embedded 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), + // 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(), + // Cycles sit directly under Stages (same block — not a separate section). + if (detailedAvailable(widget.date) && _cycles.isNotEmpty) ...[ + const SizedBox(height: Sp.x3), + _cyclesCard(), + ], + 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(), + ], + // 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: 'Consistency', + info: infoFor('regularity'), value: '${_regularity!.round()}', metric: 'regularity', + trendTitle: 'Sleep consistency'), + ]), + ]; + } + @override Widget build(BuildContext context) { + // Embedded in the Sleep screen: just the sections (its ListView scrolls). + if (widget.embedded) { + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: _sections()); + } return Scaffold( 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), _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), ], + ), ), ), ); @@ -299,6 +372,11 @@ class _SleepDetailScreenState extends State { ], ), ), + // All sleeps of the day (naps included) — the v2 multi-period view. + RoundIconButton(Ic.bed, onTap: () => Navigator.of(context).push( + themedRoute((_) => SleepPeriodsScreen(date: widget.date), + ), + )), ], ); } @@ -323,12 +401,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), ], ], ), @@ -381,7 +459,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), @@ -422,6 +500,78 @@ class _SleepDetailScreenState extends State { ); } + // ── sleep cycles (fractal-cycle method on HRV) ───────────────────────────── + + Widget _cyclesCard() { + final series = _cycleSeries; + final cycles = _cycles; + final onset = _num(_data['onset_ts'])?.toInt(); + final wake = _num(_data['wake_ts'])?.toInt(); + return ProCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('Cycles', style: AppText.h2), + const SizedBox(width: Sp.x2), + Tag('beta', color: AppColors.coral), + const Spacer(), + Text('${cycles.length} cycle${cycles.length == 1 ? '' : 's'}', + style: AppText.metricSm.copyWith(fontSize: 18)), + ], + ), + const SizedBox(height: Sp.x2), + Text( + _cyclesMean == null + ? 'Ultradian NREM–REM cycles' + : 'Average ${_hm(_cyclesMean)} per cycle', + style: AppText.captionMuted, + ), + const SizedBox(height: Sp.x4), + if (series.length >= 4 && onset != null && wake != null && wake > onset) ...[ + SizedBox( + height: 76, + child: CustomPaint( + size: Size.infinite, + painter: _CyclePainter( + series: series, + cycles: cycles, + onset: onset, + wake: wake, + line: AppColors.coral, + marker: AppColors.coralDeep, + grid: AppColors.surfaceAlt, + ), + ), + ), + const SizedBox(height: Sp.x2), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(_clock(onset), style: AppText.captionMuted), + Text(_clock(wake), style: AppText.captionMuted), + ], + ), + ] else + SizedBox( + height: 48, + child: Center( + child: Text('Not enough overnight HRV to resolve cycles', + style: AppText.captionMuted), + ), + ), + const SizedBox(height: Sp.x3), + Text( + 'Each rise-and-fall of your overnight heart-rate variability is one sleep ' + 'cycle (~90 min). Diamonds mark the boundaries between cycles.', + style: AppText.captionMuted, + ), + ], + ), + ); + } + Widget _legend() { Widget swatch(String stage) => Row( mainAxisSize: MainAxisSize.min, @@ -691,12 +841,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) ...[ @@ -706,7 +856,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 ' @@ -739,8 +889,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)), @@ -754,7 +904,7 @@ class _SleepDetailScreenState extends State { children: [ Container( padding: const EdgeInsets.all(Sp.x4), - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.coralSoft, shape: BoxShape.circle, ), @@ -823,3 +973,106 @@ class _HypnogramPainter extends CustomPainter { @override bool shouldRepaint(_HypnogramPainter old) => old.segs != segs; } + +/// Draws the overnight HRV (z-RMSSD) wave across [onset, wake] with vertical +/// boundary lines + diamonds at each detected sleep-cycle edge — the cardiac +/// analog of the paper's fractal-slope cycle figure. +class _CyclePainter extends CustomPainter { + final List> series; + final List> cycles; + final int onset; + final int wake; + final Color line; + final Color marker; + final Color grid; + _CyclePainter({ + required this.series, + required this.cycles, + required this.onset, + required this.wake, + required this.line, + required this.marker, + required this.grid, + }); + + @override + void paint(Canvas canvas, Size size) { + final span = (wake - onset).toDouble(); + if (span <= 0 || series.isEmpty) return; + + double zmin = double.infinity, zmax = -double.infinity; + for (final e in series) { + if (e.value < zmin) zmin = e.value; + if (e.value > zmax) zmax = e.value; + } + if (!(zmax > zmin)) zmax = zmin + 1; + + double xOf(int t) => (((t - onset) / span).clamp(0.0, 1.0)) * size.width; + double yOf(double z) { + final n = (z - zmin) / (zmax - zmin); // 0..1 + return size.height - n * size.height * 0.80 - size.height * 0.10; + } + + // cycle boundary verticals + final bounds = {}; + for (final c in cycles) { + final s = (c['start_ts'] as num?)?.toInt(); + final e = (c['end_ts'] as num?)?.toInt(); + if (s != null) bounds.add(s); + if (e != null) bounds.add(e); + } + final gp = Paint()..color = grid..strokeWidth = 1; + for (final t in bounds) { + final x = xOf(t); + canvas.drawLine(Offset(x, 0), Offset(x, size.height), gp); + } + + // the HRV curve + final path = Path(); + bool started = false; + for (final e in series) { + final x = xOf(e.key); + final y = yOf(e.value); + if (!started) { + path.moveTo(x, y); + started = true; + } else { + path.lineTo(x, y); + } + } + canvas.drawPath( + path, + Paint() + ..color = line + ..style = PaintingStyle.stroke + ..strokeWidth = 2.5 + ..strokeJoin = StrokeJoin.round, + ); + + // diamonds at each boundary, snapped to the curve's nearest z + final mp = Paint()..color = marker; + for (final t in bounds) { + double bestD = double.infinity, by = size.height / 2; + for (final e in series) { + final d = (e.key - t).abs().toDouble(); + if (d < bestD) { + bestD = d; + by = yOf(e.value); + } + } + final x = xOf(t); + const r = 4.0; + final dia = Path() + ..moveTo(x, by - r) + ..lineTo(x + r, by) + ..lineTo(x, by + r) + ..lineTo(x - r, by) + ..close(); + canvas.drawPath(dia, mp); + } + } + + @override + bool shouldRepaint(_CyclePainter old) => + old.series != series || old.cycles != cycles; +} diff --git a/lib/ui/sleep/sleep_periods_screen.dart b/lib/ui/sleep/sleep_periods_screen.dart new file mode 100644 index 0000000..3eaedab --- /dev/null +++ b/lib/ui/sleep/sleep_periods_screen.dart @@ -0,0 +1,344 @@ +// 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: 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), + _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)), + ], + ), + ), + 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) 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 (_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), + const SizedBox(height: Sp.x3), + _stageLegend(stages), + ], + ], + ), + ); + } + + // 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(); + 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)), + ], + ), + ), + ]); +} + +/// 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; +} 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/spotcheck/spot_check_screen.dart b/lib/ui/spotcheck/spot_check_screen.dart new file mode 100644 index 0000000..43dd884 --- /dev/null +++ b/lib/ui/spotcheck/spot_check_screen.dart @@ -0,0 +1,127 @@ +// Spot check — an on-demand ~60s live HRV reading. Enables wrist-gated optical + +// realtime records, collects beat-to-beat RR, and computes HRV server-side. Honest: +// a quick snapshot, not your nightly recovery (that's measured over a full sleep). + +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'; + +class SpotCheckScreen extends StatelessWidget { + const SpotCheckScreen({super.key}); + + @override + Widget build(BuildContext context) { + final app = context.watch(); + final connected = app.isConnected; + final active = app.spotActive; + final result = app.spotResult; + final err = app.spotError; + final remaining = app.spotRemaining; + final progress = active + ? (AppState.spotDuration - remaining) / AppState.spotDuration + : 0.0; + final liveHr = app.device.liveHr; + + 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: () { + if (active) app.cancelSpotCheck(); + Navigator.of(context).pop(); + }), + const SizedBox(width: Sp.x3), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Spot check', style: AppText.h1), + const SizedBox(height: 4), + Text('A quick live HRV reading', style: AppText.caption), + ])), + ]), + const SizedBox(height: Sp.x8), + + // The ring: countdown while scanning, else the last RMSSD or a prompt. + Center(child: RingStat( + t: active ? progress.clamp(0.0, 1.0) : (result?['ok'] == true ? 1.0 : 0.0), + color: AppColors.good, size: 200, stroke: 16, + center: active + ? Column(mainAxisSize: MainAxisSize.min, children: [ + Text('$remaining', style: AppText.display), + Text('seconds', style: AppText.caption), + if (liveHr != null && liveHr > 0) ...[ + const SizedBox(height: 4), + Text('♥ $liveHr bpm', style: AppText.captionMuted), + ], + ]) + : (result?['ok'] == true + ? Column(mainAxisSize: MainAxisSize.min, children: [ + Text('${result!['rmssd']}', style: AppText.display), + Text('ms RMSSD', style: AppText.caption), + ]) + : AppIcon(Ic.pulse, size: 56, color: AppColors.inkMuted)), + )), + const SizedBox(height: Sp.x8), + + if (active) + ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x4), child: Row(children: [ + AppIcon(Ic.info, size: 16, color: AppColors.coralDeep), + const SizedBox(width: Sp.x3), + Expanded(child: Text('Keep the band snug and sit still. Breathe normally — ' + 'movement adds noise to the reading.', style: AppText.captionMuted)), + ]))) + else if (result?['ok'] == true) ...[ + _resultCard(result!), + ] else if (err != null) + ProCard(child: Padding(padding: const EdgeInsets.all(Sp.x4), child: Text(err, style: AppText.captionMuted))), + + const SizedBox(height: Sp.x6), + + if (!active) + FilledButton.icon( + onPressed: connected ? app.startSpotCheck : null, + icon: const AppIcon(Ic.pulse, size: 18, color: Colors.white), + label: Text(result == null ? 'Start 60-second scan' : 'Scan again'), + ) + else + OutlinedButton(onPressed: app.cancelSpotCheck, child: const Text('Cancel')), + + if (!connected && !active) ...[ + const SizedBox(height: Sp.x3), + Text('Connect your band to run a spot check.', + style: AppText.captionMuted, textAlign: TextAlign.center), + ], + + const SizedBox(height: Sp.x6), + Text('A spot check is a snapshot of your current state. Your daily recovery ' + 'is measured over a full night of sleep and is more reliable for trends.', + style: AppText.captionMuted), + const SizedBox(height: 80), + ], + ), + ), + ); + } + + Widget _resultCard(Map r) => ProCard(child: Column(children: [ + _row('RMSSD', '${r['rmssd'] ?? '—'}', 'ms'), + if (r['sdnn'] != null) _row('SDNN', '${r['sdnn']}', 'ms'), + if (r['pnn50'] != null) _row('pNN50', '${r['pnn50']}', '%'), + if (r['mean_hr'] != null) _row('Mean HR', '${r['mean_hr']}', 'bpm'), + if (r['n_beats'] != null) _row('Beats analysed', '${r['n_beats']}', ''), + ])); + + Widget _row(String label, String value, String unit) => + Padding(padding: const EdgeInsets.symmetric(vertical: Sp.x2), child: Row(children: [ + Expanded(child: Text(label, style: AppText.body)), + Text(unit.isEmpty ? value : '$value $unit', style: AppText.label), + ])); +} 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/lib/ui/stress/stress_screen.dart b/lib/ui/stress/stress_screen.dart index 1398009..a49f940 100644 --- a/lib/ui/stress/stress_screen.dart +++ b/lib/ui/stress/stress_screen.dart @@ -1,5 +1,8 @@ -// Stress — arousal estimated from heart rate above resting while you're still. -// Not HRV. Backed by /day/stress. +// Stress — HRV-based sympathetic activation (Baevsky Stress Index + LF/HF), +// personal-relative. Backed by /day/stress, which returns: +// { stress:{score,si,lf_hf,rmssd,level,drivers}, sleep_stress:{...}, drivers, hr:[{t,v}] } +// (The old HR-above-resting "arousal band" model was removed — this reads the real +// HRV stress. Nocturnal arousal lives under sleep_stress.) import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -10,6 +13,7 @@ import '../../theme/theme.dart'; import '../../theme/tokens.dart'; import '../kit/kit.dart'; import '../kit/charts.dart'; +import '../screens/metric_row.dart'; class StressScreen extends StatefulWidget { final String date; // 'YYYY-MM-DD' @@ -24,6 +28,7 @@ class _StressScreenState extends State { _Phase _phase = _Phase.loading; String? _error; Map _data = const {}; + Map _hrv = const {}; // /day/hrv (daytime ultradian HRV) @override void initState() { @@ -40,10 +45,16 @@ class _StressScreenState extends State { setState(() { _phase = _Phase.loading; _error = null; }); try { final res = await api.getDayStress(widget.date); + // Daytime HRV is best-effort — don't fail the whole screen if it's absent. + Map hrv = const {}; + try { hrv = await api.getDayHrv(widget.date); } catch (_) {/* optional */} if (!mounted) return; setState(() { _data = res; - _phase = (_score == null && _wornMin == 0) ? _Phase.empty : _Phase.ready; + _hrv = hrv; + final hasStress = _stress.isNotEmpty && _score != null; + final hasSleep = _sleepStress.isNotEmpty && _sleepScore != null; + _phase = (hasStress || hasSleep) ? _Phase.ready : _Phase.empty; }); } catch (e) { if (!mounted) return; @@ -54,56 +65,23 @@ class _StressScreenState extends State { } } - // ── parsing ────────────────────────────────────────────────────────────────── - num? _num(Object? v) => - v is num ? v : (v is String ? num.tryParse(v) : null); - Map _map(Object? v) => - v is Map ? v.cast() : const {}; + // ── parsing (matches the HRV-stress response shape) ────────────────────────── + num? _num(Object? v) => v is num ? v : (v is String ? num.tryParse(v) : null); + Map _map(Object? v) => v is Map ? v.cast() : const {}; - int? get _score => _num(_data['score'])?.toInt(); - int get _wornMin => _num(_data['worn_min'])?.toInt() ?? 0; - Map get _buckets => _map(_data['buckets']); - int get _calm => _num(_buckets['calm'])?.toInt() ?? 0; - int get _balanced => _num(_buckets['balanced'])?.toInt() ?? 0; - int get _stressed => _num(_buckets['stressed'])?.toInt() ?? 0; - int get _active => _num(_buckets['active'])?.toInt() ?? 0; + Map get _stress => _map(_data['stress']); + Map get _sleepStress => _map(_data['sleep_stress']); + int? get _score => _num(_stress['score'])?.toInt(); + int? get _sleepScore => _num(_sleepStress['score'])?.toInt(); + List get _hr => ((_data['hr'] as List?) ?? const []) + .map((e) => (_num((e as Map)['v']) ?? 0).toDouble()) + .where((v) => v > 0) + .toList(); + List get _drivers => + ((_data['drivers'] as List?) ?? const []).whereType() + .where((d) => (d['label']?.toString() ?? '').isNotEmpty).toList(); - /// epoch-seconds at UTC midnight of the displayed date (band positioning). - int get _dayStart { - final p = widget.date.split('-'); - if (p.length != 3) return 0; - final y = int.tryParse(p[0]), mo = int.tryParse(p[1]), d = int.tryParse(p[2]); - if (y == null || mo == null || d == null) return 0; - return DateTime.utc(y, mo, d).millisecondsSinceEpoch ~/ 1000; - } - - List<({double frac, Color color})> _bandPoints() { - final raw = _data['band']; - if (raw is! List) return const []; - final out = <({double frac, Color color})>[]; - for (final p in raw) { - final m = _map(p); - final t = _num(m['t'])?.toInt(); - final b = m['b']?.toString() ?? 'none'; - if (t == null) continue; - final frac = ((t - _dayStart) / 86400.0).clamp(0.0, 1.0); - out.add((frac: frac, color: _bucketColor(b))); - } - out.sort((a, b) => a.frac.compareTo(b.frac)); - return out; - } - - Color _bucketColor(String b) { - switch (b) { - case 'calm': return AppColors.good; - case 'balanced': return AppColors.coral.withValues(alpha: 0.55); - case 'stressed': return AppColors.warn; - case 'active': return AppColors.loadDetraining; // moving (exertion, not stress) - default: return AppColors.surfaceAlt; - } - } - - /// Day-stress score → color (LOW stress is good; HIGH is warn/bad). + /// Stress score → color (LOW stress is good; HIGH is warn/bad). Color _scoreColor(int v) { if (v < 25) return AppColors.good; if (v < 50) return AppColors.coral; @@ -111,8 +89,8 @@ class _StressScreenState extends State { return AppColors.bad; } - String _band(int v) => v < 25 ? 'Calm day' - : v < 50 ? 'Balanced' : v < 75 ? 'Elevated' : 'High arousal'; + String _bandLabel(int v) => + v < 25 ? 'Low' : v < 50 ? 'Moderate' : v < 75 ? 'Elevated' : 'High'; String _hm(int m) { if (m <= 0) return '0m'; @@ -122,12 +100,6 @@ class _StressScreenState extends State { return '${h}h ${r}m'; } - String _clock(int? epochSec) { - if (epochSec == null) return '—'; - final t = DateTime.fromMillisecondsSinceEpoch(epochSec * 1000).toLocal(); - return '${t.hour}:${t.minute.toString().padLeft(2, '0')}'; - } - static const _months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; static const _weekdays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; String _prettyDate() { @@ -155,20 +127,19 @@ class _StressScreenState extends State { if (_phase == _Phase.loading) _loading() else if (_phase == _Phase.empty) - _stateCard(Ic.pulse, 'No stress data for this day', - 'Stress needs heart rate during still, sedentary minutes. Wear your ' - 'strap through the day and sync to see your arousal pattern.') + _stateCard(Ic.pulse, 'No stress reading for this day', + 'Stress is computed from your overnight HRV (beat-to-beat heart data). ' + 'Wear your strap through the night and sync — it needs a few nights of ' + 'baseline before it can score you against your own normal.') else if (_phase == _Phase.error) _stateCard(Ic.cloud, "Couldn't load stress", _error ?? 'Please try again.') else ...[ - _hero(), - const SizedBox(height: Sp.x6), - const SectionHeader('Across the day'), - _bandCard(), - const SizedBox(height: Sp.x6), - const SectionHeader('Time in each state'), - _breakdownCard(), - ..._peakSection(), + if (_score != null) _hero(), + ..._hrvSection(), + ..._daytimeHrvSection(), + ..._timelineSection(), + ..._sleepStressSection(), + ..._driversSection(), const SizedBox(height: Sp.x6), _honesty(), ], @@ -182,50 +153,46 @@ class _StressScreenState extends State { 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('Stress', style: AppText.h1), - const SizedBox(height: 2), - Text(_prettyDate(), style: AppText.caption), - ], - ), - ), + Expanded(child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('Stress', style: AppText.h1), + const SizedBox(height: 2), + Text(_prettyDate(), style: AppText.caption), + ], + )), ]); Widget _hero() { - final v = _score ?? 0; + final v = _score!; final color = _scoreColor(v); + final level = (_stress['level']?.toString().trim().isNotEmpty ?? false) + ? _cap(_stress['level'].toString()) + : _bandLabel(v); return GlowCard( padding: const EdgeInsets.all(Sp.x6), child: Row(children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row(children: [ - const 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), - ]), - const SizedBox(height: Sp.x3), - Text('$v', style: AppText.display.copyWith(color: color)), - const SizedBox(height: Sp.x1), - Text('${_band(v)} · ${_hm(_wornMin)} worn', style: AppText.bodySoft), - ], - ), - ), + Expanded(child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row(children: [ + AppIcon(Ic.pulse, size: 16, color: AppColors.coralDeep), + const SizedBox(width: Sp.x2), + Text('STRESS', style: AppText.overline), + const SizedBox(width: Sp.x2), + Tag('HRV', color: AppColors.good), + ]), + const SizedBox(height: Sp.x3), + Text('$v', style: AppText.display.copyWith(color: color)), + const SizedBox(height: Sp.x1), + Text('$level · sympathetic activation from your HRV', style: AppText.bodySoft), + ], + )), const SizedBox(width: Sp.x4), RingStat( - t: (v / 100).clamp(0.0, 1.0), - color: color, - size: 104, - stroke: 11, + t: (v / 100).clamp(0.0, 1.0), color: color, size: 104, stroke: 11, center: Column(mainAxisSize: MainAxisSize.min, children: [ Text('$v', style: AppText.metricSm.copyWith(fontSize: 20, color: color)), Text('/100', style: AppText.captionMuted), @@ -235,127 +202,125 @@ class _StressScreenState extends State { ); } - Widget _bandCard() { - final pts = _bandPoints(); - return ProCard( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (pts.isEmpty) - SizedBox(height: 40, child: Center( - child: Text('No minute data', style: AppText.captionMuted))) - else - ClipRRect( - borderRadius: BorderRadius.circular(R.pill), - child: SizedBox( - height: 30, - child: CustomPaint(size: Size.infinite, painter: _BandPainter(pts)), - ), - ), - const SizedBox(height: Sp.x2), - Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: const [ - 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)), - Text('6p', style: TextStyle(fontSize: 10, color: AppColors.inkMuted)), - Text('12a', style: TextStyle(fontSize: 10, color: AppColors.inkMuted)), - ]), - const SizedBox(height: Sp.x3), - Wrap(spacing: Sp.x4, runSpacing: Sp.x2, children: [ - _legend(AppColors.good, 'Calm'), - _legend(AppColors.coral.withValues(alpha: 0.55), 'Balanced'), - _legend(AppColors.warn, 'Stressed'), - _legend(AppColors.loadDetraining, 'Active'), - ]), + List _hrvSection() { + final si = _num(_stress['si']); + final lfhf = _num(_stress['lf_hf']); + final rmssd = _num(_stress['rmssd']); + if (si == null && lfhf == null && rmssd == null) return const []; + return [ + const SizedBox(height: Sp.x6), + const SectionHeader('How it was measured'), + MetricGroup([ + if (si != null) + MetricRow(icon: Ic.strain, accent: AppColors.warn, label: 'Stress index (Baevsky)', + info: 'Baevsky SI from your HRV — higher = more sympathetic activation. Scored vs your own baseline.', + value: '$si'), + if (lfhf != null) + MetricRow(icon: Ic.pulse, accent: AppColors.warn, label: 'Sympatho-vagal balance', + info: infoFor('lf_hf'), value: '$lfhf'), + if (rmssd != null) + MetricRow(icon: Ic.pulse, accent: AppColors.good, label: 'RMSSD (this read)', + info: infoFor('rmssd'), value: '$rmssd', unit: 'ms'), ]), - ); + ]; } - Widget _legend(Color c, String label) => Row(mainAxisSize: MainAxisSize.min, children: [ - Container(width: 11, height: 11, decoration: BoxDecoration( - color: c, borderRadius: BorderRadius.circular(3))), - const SizedBox(width: 6), - Text(label, style: AppText.caption), - ]); - - Widget _breakdownCard() { - final total = _calm + _balanced + _stressed + _active; - Widget row(String label, int min, Color c) { - final pct = total > 0 ? min / total : 0.0; - return Padding( - padding: const EdgeInsets.symmetric(vertical: Sp.x2), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Container(width: 10, height: 10, decoration: BoxDecoration( - color: c, borderRadius: BorderRadius.circular(3))), - const SizedBox(width: Sp.x3), - Expanded(child: Text(label, style: AppText.title)), - Text(_hm(min), style: AppText.metricSm.copyWith(fontSize: 17)), - const SizedBox(width: Sp.x3), - SizedBox(width: 42, child: Text('${(pct * 100).round()}%', - textAlign: TextAlign.right, style: AppText.caption)), - ]), - const SizedBox(height: Sp.x2), - ClipRRect( - borderRadius: BorderRadius.circular(R.pill), - child: SizedBox(height: 6, child: Row(children: [ - Expanded(flex: (pct * 1000).round().clamp(0, 1000), child: Container(color: c)), - Expanded(flex: (1000 - (pct * 1000).round()).clamp(1, 1000), - child: Container(color: AppColors.surfaceAlt)), - ])), - ), - ]), - ); + List _timelineSection() { + if (!detailedAvailable(widget.date)) { + return const [SizedBox(height: Sp.x6), DetailRetentionNote(what: 'the minute-by-minute heart rate')]; } - return ProCard(child: Column(children: [ - row('Calm', _calm, AppColors.good), - row('Balanced', _balanced, AppColors.coral.withValues(alpha: 0.55)), - row('Stressed', _stressed, AppColors.warn), - row('Active (moving)', _active, AppColors.loadDetraining), - ])); + final hr = _hr; + if (hr.length < 2) return const []; + return [ + const SizedBox(height: Sp.x6), + const SectionHeader('Heart rate (context)'), + ProCard(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + AreaSpark(hr, color: AppColors.coral, height: 90), + const SizedBox(height: Sp.x2), + Text('Your heart rate across the day — shown for context, not the stress score.', + style: AppText.captionMuted), + ])), + ]; } - List _peakSection() { - final p = _map(_data['peak']); - final ts = _num(p['t'])?.toInt(); - final sc = _num(p['score'])?.toInt(); - if (ts == null || sc == null) return const []; + List _sleepStressSection() { + final ss = _sleepStress; + if (ss.isEmpty || _sleepScore == null) return const []; + final arousals = _num(ss['arousal_events'])?.toInt() ?? 0; + final restless = _num(ss['restless_min'])?.toInt() ?? 0; + // Richer movement-restlessness (calcRestlessness), distinct from HR-surge arousal. + final rest = _map(ss['restlessness']); + final bouts = _num(rest['movement_bouts'])?.toInt(); + final stillMin = _num(rest['longest_still_min'])?.toInt(); return [ const SizedBox(height: Sp.x6), - const SectionHeader('Peak arousal'), - ProCard(child: Row(children: [ - Container( - 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), - ), - const SizedBox(width: Sp.x4), - Expanded(child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text('Highest around ${_clock(ts)}', style: AppText.title), - const SizedBox(height: 2), - Text('Arousal hit $sc/100 — a stretch of elevated heart rate while still.', - style: AppText.bodySoft), - ], - )), + const SectionHeader('Overnight arousal'), + ProCard(child: Column(children: [ + MetricRow(icon: Ic.moon, accent: AppColors.loadDetraining, label: 'Sleep stress', + info: 'Possible arousals overnight — brief heart-rate surges with movement during sleep.', + value: '$_sleepScore', unit: '/100'), + if (arousals > 0 || restless > 0) + MetricRow(icon: Ic.activity, accent: AppColors.inkSoft, label: 'Disturbance', + info: 'Count of possible arousals and total restless time.', + value: '$arousals events · ${_hm(restless)}'), + if (bouts != null) + MetricRow(icon: Ic.activity, accent: AppColors.inkSoft, label: 'Restlessness', + info: 'How fragmented the night was — number of times you shifted, and your longest unbroken still stretch.', + value: stillMin != null ? '$bouts shifts · ${_hm(stillMin)} still' : '$bouts shifts'), + ])), + ]; + } + + // Daytime (waking) HRV — the ultradian RMSSD rhythm from /day/hrv. Complements the + // nocturnal recovery score: a sense of autonomic state across the day. + List _daytimeHrvSection() { + final h = _map(_hrv['daytime_hrv']); + final med = _num(h['rmssd_median'])?.toInt(); + final n = _num(h['n_windows'])?.toInt() ?? 0; + if (med == null || n < 3) return const []; + return [ + const SizedBox(height: Sp.x6), + const SectionHeader('Daytime HRV'), + ProCard(child: Column(children: [ + MetricRow(icon: Ic.pulse, accent: AppColors.good, label: 'Median daytime RMSSD', + info: 'Heart-rate variability across your waking hours (ultradian rhythm), excluding your main sleep. Higher generally means more recovered/parasympathetic.', + value: '$med', unit: 'ms'), + MetricRow(icon: Ic.activity, accent: AppColors.inkSoft, label: 'Coverage', + info: 'Number of 5-minute windows with enough beat-to-beat data to measure.', + value: '$n', unit: 'windows'), + ])), + ]; + } + + List _driversSection() { + final d = _drivers; + if (d.isEmpty) return const []; + return [ + const SizedBox(height: Sp.x6), + const SectionHeader('What affected this'), + ProCard(child: Column(children: [ + for (final dr in d) + DetailRow(label: dr['label']?.toString() ?? '', value: dr['detail']?.toString() ?? ''), ])), ]; } 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 ' - 'still — NOT HRV. It can\'t tell stress from caffeine, excitement or a ' - 'warm room. Moving minutes are counted as activity, not stress.', + 'Daytime stress here is your nocturnal HRV (Baevsky Stress Index), scored ' + 'against your own baseline — sympathetic "fight-or-flight" load, not a mood. ' + 'Overnight arousal is brief heart-rate surges with movement during sleep, not ' + 'nightmares. Both need a few nights of HRV to be meaningful.', style: AppText.captionMuted, )), ]); - Widget _loading() => const ProCard( - padding: EdgeInsets.all(Sp.x6), + String _cap(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1); + + Widget _loading() => ProCard( + padding: const EdgeInsets.all(Sp.x6), child: SizedBox(height: 320, child: Center(child: CircularProgressIndicator(color: AppColors.coral))), ); @@ -365,7 +330,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), @@ -377,22 +342,3 @@ class _StressScreenState extends State { ]), ); } - -/// Paints the 24h arousal band: each point colors the sliver from its frac to -/// the next point's frac (full-width = the day). Gaps render as the track color. -class _BandPainter extends CustomPainter { - final List<({double frac, Color color})> pts; - _BandPainter(this.pts); - @override - void paint(Canvas canvas, Size size) { - canvas.drawRect(Offset.zero & size, Paint()..color = AppColors.surfaceAlt); - for (int i = 0; i < pts.length; i++) { - final x0 = pts[i].frac * size.width; - final x1 = (i + 1 < pts.length ? pts[i + 1].frac : pts[i].frac + 1 / 240) * size.width; - final w = (x1 - x0).clamp(1.0, size.width); - canvas.drawRect(Rect.fromLTWH(x0, 0, w, size.height), Paint()..color = pts[i].color); - } - } - @override - bool shouldRepaint(_BandPainter old) => old.pts != pts; -} diff --git a/lib/ui/today/step_goal_screen.dart b/lib/ui/today/step_goal_screen.dart new file mode 100644 index 0000000..e179a19 --- /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 + ? 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 8c70b64..d989033 100644 --- a/lib/ui/today/today_screen.dart +++ b/lib/ui/today/today_screen.dart @@ -8,16 +8,18 @@ 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'; 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'; -import '../recovery/recovery_screen.dart'; -import '../activity/strain_detail_screen.dart'; -import '../sleep/sleep_detail_screen.dart'; +import '../profile/profile_screen.dart'; +import '../screens/screens.dart'; import '../journey/journey_screen.dart'; import '../stress/stress_screen.dart'; import '../records/records_screen.dart'; @@ -115,6 +117,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!), @@ -172,12 +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(() => + 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())), ], ); } @@ -189,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(); @@ -220,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), @@ -237,17 +249,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 +258,12 @@ 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. + // 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), @@ -277,19 +273,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 +286,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 +295,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( @@ -332,6 +305,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, @@ -339,6 +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()), ), ), const SizedBox(height: Sp.x3), @@ -349,16 +327,10 @@ 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)), ), - _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)', @@ -366,8 +338,11 @@ 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), + _statRow( StatTile( icon: Ic.heart, label: 'Blood O₂ (rel.)', @@ -376,8 +351,9 @@ 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(), ), const SizedBox(height: Sp.x4), @@ -404,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, @@ -431,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: [ @@ -440,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)), @@ -448,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) ...[ @@ -469,6 +445,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: [ + 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); @@ -482,19 +489,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 BodyScreen())), _gauge('SLEEP', t.sleepDuration.isEmpty ? null : (t.sleepDuration.value! / 60).toStringAsFixed(1), - 'h', sleepT, AppColors.loadDetraining), + 'h', sleepT, AppColors.loadDetraining, + onTap: () => _push(() => const SleepScreen())), _gauge('HRV', hrv == null ? null : hrv.rmssd.toStringAsFixed(0), - 'ms', hrvT, AppColors.good), + 'ms', hrvT, AppColors.good, + onTap: () => _push(() => const HeartScreen())), ], ), ); } - 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 +530,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)!, - ], - ], - ), ), ), ); @@ -579,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, @@ -590,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, @@ -615,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), @@ -641,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), @@ -663,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), @@ -680,18 +648,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/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/widgets/status_banner.dart b/lib/ui/widgets/status_banner.dart new file mode 100644 index 0000000..7936aac --- /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: AppColors.surface.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(R.chip), + ), + child: 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: AppColors.surface.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: 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 new file mode 100644 index 0000000..d559411 --- /dev/null +++ b/lib/ui/workouts/workouts_screen.dart @@ -0,0 +1,690 @@ +// 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 '../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'; +import '../screens/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 + +// Zone palette (Z1→Z5), shared by the bar + legend. +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; } + 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( + 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 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(themedRoute((_) => LiveSessionScreen(workoutId: id, type: type), + )); + } catch (_) {/* surfaced as no-op; user can retry */} +} + +/// Bottom-sheet type picker (no workout start) — used to confirm/correct an +/// auto-detected workout's type. Returns the chosen type, or null if dismissed. +Future pickWorkoutType(BuildContext context, {String title = 'Set workout type'}) { + return 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(title, 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), + ]), + ), + ); +} + +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, + 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 Spacer(), + _StartButton(onTap: () => startWorkoutFlow(context).then((_) => _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['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: [ + 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 ...[ + SectionHeader(_range == 0 ? 'Today' : 'Sessions'), + for (final w in list) _row(w as Map), + ], + ], + ], + ), + ), + ), + ); + } + + // Swipe-to-delete wrapper. Live sessions aren't deletable (finish them first). + // Confirm/correct an auto-detected workout's type → feeds the classifier calibration + // ledger, and pins the type so re-derivation won't overwrite it. + Future _correctType(Map w) async { + final id = w['id'] as String?; + if (id == null) return; + final t = await pickWorkoutType(context, title: 'Correct workout type'); + if (t == null || !mounted) return; + final api = context.read().api; + if (api == null) return; + try { await api.setWorkoutType(id, t); _load(); } catch (_) {/* retryable */} + } + + Widget _row(Map w) { + final tile = _WorkoutTile(w, onCorrect: () => _correctType(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: Row(mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.delete_outline, size: 20, color: AppColors.bad), + const 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: 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, +/// 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: 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; + 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; + + 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'), + ]), + // Classifier calibration: how often the auto-detected type matched what you + // confirmed/corrected. Shown once you've reviewed at least one — tells us (and + // you) when the activity model needs retraining. + ...(() { + final cls = (summary['classifier'] as Map?)?.cast(); + final acc = (cls?['accuracy'] as num?)?.toDouble(); + final reviewed = (cls?['reviewed'] as num?)?.toInt() ?? 0; + if (acc == null || reviewed <= 0) return const []; + return [ + const SizedBox(height: Sp.x5), + Row(children: [ + AppIcon(Icons.verified_outlined, size: 15, color: AppColors.inkMuted), + const SizedBox(width: Sp.x2), + Text('Type accuracy ${(acc * 100).round()}% · $reviewed reviewed', style: AppText.caption), + ]), + ]; + })(), + 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), + ]), + ]), + ], + ]), + ); + } + + 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 { + final Map w; + final VoidCallback? onCorrect; + const _WorkoutTile(this.w, {this.onCorrect}); + @override + Widget build(BuildContext context) { + final live = w['status'] == 'live'; + final detected = w['detected'] == true; // auto / auto_live + final phases = (w['segments'] as List?)?.length ?? 0; + 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( + 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), + decoration: BoxDecoration(color: AppColors.coral.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(R.chip)), + 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(_typeLabel(w['type'] as String?), style: AppText.label), + if (live) ...[const SizedBox(width: Sp.x2), Tag('LIVE', color: AppColors.coral)] + else if (detected) ...[const SizedBox(width: Sp.x2), Tag('DETECTED', color: AppColors.inkMuted)] + else ...[const SizedBox(width: Sp.x2), Tag('LOGGED', color: AppColors.inkMuted)], + if (phases > 1) ...[const SizedBox(width: Sp.x2), Text('· $phases phases', style: AppText.captionMuted)], + ]), + const SizedBox(height: 2), + Text('${_whenLabel(w['start_ts'] as int?)} · ${hm(w['duration_min'] as num?)} · ${noData ? 'no HR' : '${w['avg_hr'] ?? '—'} bpm'}', + style: AppText.captionMuted), + ])), + // Correct an auto-detected type (calibration). Manual workouts keep their type. + if (!live && detected && onCorrect != null) ...[ + GestureDetector( + onTap: onCorrect, + behavior: HitTestBehavior.opaque, + child: Padding(padding: const EdgeInsets.all(6), + child: AppIcon(Icons.edit_outlined, size: 16, color: AppColors.inkMuted)), + ), + ], + if (!live) ...[ + 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), + ]), + ), + ); + } +} + + +/// Post-workout breakdown (also the tap target from the list). +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: 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), + actions: [ + IconButton( + icon: Icon(Icons.delete_outline, color: AppColors.inkMuted), + tooltip: 'Delete workout', + onPressed: () => _delete(context), + ), + ], + ), + 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); } + } + + 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 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']); + // 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/ + // 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 ── + 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') Tag('AUTO', color: AppColors.inkMuted), + if (live) 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 && !noData) + 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), + ]), + ), + ]), + 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(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'), + ]), + ]), + ), + + // ── 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), + const SizedBox(height: Sp.x3), + AreaSpark(hr, color: AppColors.coral, height: 110), + if (drift != null || ttp != null) ...[ + const SizedBox(height: Sp.x4), + 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), + ), + ], + ])), + ], + + // ── 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([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)), + ]), + ], + ])), + ], + + // ── 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), + ])); +} diff --git a/lib/widget/widget_service.dart b/lib/widget/widget_service.dart index 71cae23..893fa21 100644 --- a/lib/widget/widget_service.dart +++ b/lib/widget/widget_service.dart @@ -4,27 +4,45 @@ // process reads these keys; it never runs Dart. // // The App Group id MUST match the one set in Xcode (Runner + widget targets) and -// in the Swift suite name. See WIDGET_SETUP.md. +// in the Swift suite name. See guides/IOS_INSTALLATION.md. import 'package:home_widget/home_widget.dart'; +import 'package:flutter/services.dart'; import '../models/payloads.dart'; class WidgetService { - /// App Group id — keep in sync with Xcode entitlements + the Swift suite name. - static const String appGroupId = 'group.wtf.openstrap'; + static const _platform = MethodChannel('openstrap/ios_config'); + + /// Fallback App Group id. iOS builds read the configured value from Info.plist. + static const String fallbackAppGroupId = String.fromEnvironment( + 'APP_GROUP_IDENTIFIER', + defaultValue: 'group.com.example.openstrap', + ); + static String appGroupId = fallbackAppGroupId; /// 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; static Future init() async { if (_inited) return; try { + final configured = await _platform.invokeMethod( + 'appGroupIdentifier', + ); + if (configured != null && configured.isNotEmpty) { + appGroupId = configured; + } await HomeWidget.setAppGroupId(appGroupId); _inited = true; - } catch (_) {/* platform without widgets — ignore */} + } catch (_) { + /* platform without widgets — ignore */ + } } /// Push the latest snapshot and trigger a widget reload. Best-effort; never @@ -32,30 +50,89 @@ class WidgetService { static Future push(TodayData t) async { try { await init(); - final r = t.readiness; + final hrv = t.hrv; final s = t.strain; final sleep = t.sleepDuration; final need = t.sleepNeed; final rhr = t.restingHr; - Future setI(String k, int v) => HomeWidget.saveWidgetData(k, v); + 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()); + // 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( - 'strain', s.isEmpty ? -1.0 : s.value!.toDouble()); + 'strain', + s.isEmpty ? -1.0 : s.value!.toDouble(), + ); await setI('sleep_min', sleep.isEmpty ? -1 : sleep.value!.round()); await setI('sleep_need_min', need.isEmpty ? 480 : need.value!.round()); await setI('rhr', rhr.isEmpty ? -1 : rhr.value!.round()); - await HomeWidget.saveWidgetData('coach_line', _coachLine(t.coach)); - await HomeWidget.saveWidgetData('stress_band', - t.stress?.band ?? ''); + await HomeWidget.saveWidgetData( + 'coach_line', + _coachLine(t.coach), + ); + await HomeWidget.saveWidgetData( + 'stress_band', + t.stress?.band ?? '', + ); await setI('updated_at', DateTime.now().millisecondsSinceEpoch ~/ 1000); - await HomeWidget.updateWidget(iOSName: _iOSName, androidName: _androidName); + await HomeWidget.updateWidget( + iOSName: _iOSName, + androidName: _androidName, + ); + } 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 + /// 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 { @@ -71,7 +148,10 @@ class WidgetService { static Future consumeEndSessionFlag() async { try { await init(); - final v = await HomeWidget.getWidgetData('end_session', defaultValue: false); + final v = await HomeWidget.getWidgetData( + 'end_session', + defaultValue: false, + ); if (v == true) { await HomeWidget.saveWidgetData('end_session', false); return true; diff --git a/pubspec.lock b/pubspec.lock index 802c0c4..cc6ee0a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -278,6 +278,94 @@ 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_math_fork: + dependency: transitive + description: + name: flutter_math_fork + sha256: "6d5f2f1aa57ae539ffb0a04bb39d2da67af74601d685a161aff7ce5bda5fa407" + url: "https://pub.dev" + source: hosted + version: "0.7.4" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" + url: "https://pub.dev" + source: hosted + version: "10.3.1" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" + url: "https://pub.dev" + source: hosted + version: "0.3.2" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5 + url: "https://pub.dev" + source: hosted + version: "3.0.1" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -312,6 +400,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.3.3" + gpt_markdown: + dependency: "direct main" + description: + name: gpt_markdown + sha256: c14c2a4599a67df5b6a984808cbb7631b8d15c91984ffdd7998597b93a6ff136 + url: "https://pub.dev" + source: hosted + version: "1.1.7" home_widget: dependency: "direct main" description: @@ -368,6 +464,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.8.0" + installed_apps: + dependency: "direct main" + description: + name: installed_apps + sha256: "62b9cb196e2b34558ef0586024251e5dcc5a08199ea0c55c708f0ecc53116341" + url: "https://pub.dev" + source: hosted + version: "2.1.1" io: dependency: transitive description: @@ -488,6 +592,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + notification_listener_service: + dependency: "direct main" + description: + name: notification_listener_service + sha256: "79cdf3e8b1eedc66aa0f3fce46f2f7bba5752f33d6566396bee7598882ac931d" + url: "https://pub.dev" + source: hosted + version: "1.0.0" objective_c: dependency: transitive description: @@ -496,6 +608,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.4.1" + ota_update: + dependency: "direct main" + description: + name: ota_update + sha256: "1f4c7c3c4f306729a6c00b84435096ce2d8b28439013f7237173acc699b2abc8" + url: "https://pub.dev" + source: hosted + version: "7.1.0" package_config: dependency: transitive description: @@ -504,6 +624,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: @@ -512,6 +648,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -869,6 +1013,22 @@ 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" + tuple: + dependency: transitive + description: + name: tuple + sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 + url: "https://pub.dev" + source: hosted + version: "2.0.2" typed_data: dependency: transitive description: @@ -877,6 +1037,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 +1069,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: @@ -917,6 +1109,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.3" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "142a9146f447d15b10bdc00e21d5f4d83e5b32bb5f8f8f5a04c75311344923a3" + url: "https://pub.dev" + source: hosted + version: "1.2.6" vector_math: dependency: transitive description: @@ -981,38 +1197,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..aa04d16 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.5.2+14 environment: sdk: ^3.11.4 @@ -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 @@ -36,13 +40,23 @@ 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 + # 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 + ota_update: ^7.1.0 + url_launcher: ^6.3.0 + notification_listener_service: ^1.0.0 + installed_apps: ^2.1.1 + flutter_secure_storage: ^10.3.1 + gpt_markdown: ^1.1.7 + dev_dependencies: flutter_test: sdk: flutter diff --git a/test/protocol_test.dart b/test/protocol_test.dart index 830696d..39371e7 100644 --- a/test/protocol_test.dart +++ b/test/protocol_test.dart @@ -102,14 +102,11 @@ void main() { test('record 0 matches whoop.py exactly', () { final r = parseR24(hexToBytes(records[0]['hex'] as String))!; + // Edge decodes the HEADER only (ts/counter/hr); the cloud owns the sensor + // block. Sensor-field decoding is covered by the cloud golden test + // (openstrap-protocol/ts/test_decoder.ts) + the Python reference. expect(r.tsEpoch, 1775395266); expect(r.hr, 98); - expect(r.spo2, 94); - expect(r.skinTempC, closeTo(33.5, 0.001)); - expect(r.restingHr, 81); - expect(r.accelG[0], closeTo(-0.1502, 0.0005)); - expect(r.accelG[1], closeTo(-0.3311, 0.0005)); - expect(r.accelG[2], closeTo(1.0006, 0.0005)); }); test('all 550 records decode without throwing', () {