diff --git a/PulseLoop/Services/RingSyncCoordinator.swift b/PulseLoop/Services/RingSyncCoordinator.swift index 44a1bed..d760976 100644 --- a/PulseLoop/Services/RingSyncCoordinator.swift +++ b/PulseLoop/Services/RingSyncCoordinator.swift @@ -85,6 +85,65 @@ struct SpotMeasurementGate { var modesInFlight: Set { Set(inFlight.keys.map(\.mode)) } } +/// The bpm samples of one spot HR measurement, and the rule for whether they add up to a reading. +/// +/// Two things make a raw bpm untrustworthy, and this owns both: +/// +/// * **The cached echo.** The ring replies with its last stored bpm the instant the manual-HR command +/// is sent — before the sensor has read anything. Everything inside `warmup` is therefore dropped; +/// without that, a measurement "succeeds" in two seconds on a number from hours ago. +/// * **Scatter.** Finger motion and poor contact make the PPG estimate jump around instead of holding +/// within a few beats. A majority of the window must agree (`band`, `majority`) or we report nothing: +/// a heart rate the user has no reason to doubt, but shouldn't trust, is worse than an honest retry. +private struct HRSampleWindow { + /// Discard window for the cached echo described above. + private let warmup: TimeInterval = 5 + /// A gap this long between collected samples means we've stopped getting real data (ring slipped). + private let contactGap: TimeInterval = 3 + private let minSamples = 6 + private let band = 8 // bpm neighbourhood around the median + private let majority = 0.6 // this much of the window must sit inside that band + + private var startedAt: Date? + private var samples: [Int] = [] + private var lastSampleAt: Date? + + /// True once a *real* (post-warm-up) reading has landed — which is what distinguishes a fresh + /// measurement from the stale `latestHRValue` still on screen from the last one. + var receivedReading: Bool { !samples.isEmpty } + + mutating func begin(at now: Date = Date()) { + startedAt = now + samples = [] + lastSampleAt = nil + } + + /// Collect a sample, unless it's still inside the warm-up echo. + mutating func collect(_ bpm: Int, at now: Date = Date()) { + guard let startedAt, now.timeIntervalSince(startedAt) >= warmup else { return } + samples.append(bpm) + lastSampleAt = now + } + + /// Contact lost: readings had begun, and then stopped arriving. Never true during the warm-up, + /// since nothing has been collected yet. + func contactLost(at now: Date = Date()) -> Bool { + guard let lastSampleAt else { return false } + return now.timeIntervalSince(lastSampleAt) > contactGap + } + + /// The settled reading: the median of the samples that agree with each other — or nil if they + /// never did. + var stableValue: Int? { + guard samples.count >= minSamples else { return nil } + let sorted = samples.sorted() + let median = sorted[sorted.count / 2] + let cluster = sorted.filter { abs($0 - median) <= band } // stays sorted + guard Double(cluster.count) >= Double(samples.count) * majority else { return nil } + return cluster[cluster.count / 2] + } +} + /// High-level orchestration of ring command flows. Subscribes to `PulseEventBus` to track the /// latest measurement values and completion signals, and exposes app-facing actions /// (`syncNow`, `measureHR`, `measureSpO2`, `querySleep`, `setGoal`). It only *orchestrates* @@ -164,9 +223,12 @@ final class RingSyncCoordinator { /// Set when the ring reports a completed HR measurement with no usable reading (not worn), so a /// spot measurement can fail fast instead of waiting out the full window. private var hrNoReadingReported = false - /// True once the *current* measurement has produced at least one real bpm — distinguishes a fresh - /// reading from a stale `latestHRValue` left on screen from a previous measurement. - private var measurementReceivedReading = false + /// The samples of the HR measurement in flight, and the rule for whether they settled — see + /// `HRSampleWindow`, which owns the warm-up echo and the consistency gate. + private var hrWindow = HRSampleWindow() + /// True once the current measurement has produced a real (post-warm-up) bpm. Drives the live value + /// in the measurement sheet, and keeps a stale `latestHRValue` from passing for a fresh reading. + var measurementReceivedReading: Bool { hrWindow.receivedReading } /// The spot measurements in flight, and which of them the ring has refused — see /// `SpotMeasurementGate`, which owns the rule that a refusal can only ever abort the measurement it @@ -188,12 +250,16 @@ final class RingSyncCoordinator { // — plus enough headroom that a slow session is not a failed one. A window is a ceiling, never a // wait: every measurement returns the instant its value lands (and now, on a ring that refuses the // measurement outright, the instant it says so — see `SpotMeasurementGate`). - - /// A Colmi manual HR reading can need 15–30s of on-finger warm-up (observed: ~19 s). - private let hrMeasureSeconds: UInt64 = 30 - /// After the first valid bpm, keep reading this long so the reported value is settled, not a jumpy - /// first sample. - private let hrSettleSeconds: Int = 4 + // + // **HR is the one exception**, and deliberately so: it samples its window to the end rather than + // returning on the first value, because the first value is a lie (see `hrWarmupSeconds`). It is + // therefore the only measurement whose duration is known up front — which is why it is the only one + // the UI can honestly count down. The rest finish when they finish. + + /// A Colmi manual HR reading can need 15–30s of on-finger warm-up (observed: ~19 s). Not `private`: + /// this is the one real fixed window, and `MeasurementSheet` derives its countdown from it rather + /// than copying the literal (a copy would silently desync the ring's fill from the measurement). + let hrMeasureSeconds: UInt64 = 30 /// **Raised from 40 s.** The R99's successful SpO₂ sweep took 38 s — inside a 40 s window by two /// seconds — and an earlier attempt in the same session ran past 41 s with no result. At 40 s the /// outcome was a coin toss: the user watched the ring's red LED work and got an error anyway. 60 s @@ -401,8 +467,13 @@ final class RingSyncCoordinator { try? context.save() } - /// Start HR streaming, collect for the warm-up window, stop, and return the latest bpm. - /// Samples are persisted as they arrive by `EventPersistenceSubscriber`. + /// Start HR streaming, sample the whole window, stop, and return the median of the samples that + /// agree with each other — or nil if they never settled. Samples are persisted as they arrive by + /// `EventPersistenceSubscriber`. + /// + /// Unlike every other spot measurement, this one does **not** return on the first value: the ring + /// echoes its last *cached* bpm the instant the manual-HR command fires, so returning early reports + /// a stale number — a reading from hours ago, after a convincing two seconds of "measuring". @discardableResult func measureHR() async -> Int? { guard hrState != .measuring else { return nil } @@ -411,32 +482,35 @@ final class RingSyncCoordinator { // NOTE: do *not* clear `latestHRValue` — it's the live value the workout UI shows, so a new // measurement keeps the last reading on screen until a fresh one replaces it (no blanking to —). hrNoReadingReported = false - measurementReceivedReading = false + hrWindow.begin() // Spot reading: the engine picks the right command (jring live stream / Colmi manual 0x69 // continuous stream). Always stop the stream when we're done so the ring doesn't keep measuring. let token = spot.begin(mode: YCBTMeasurementMode.heartRate) engine?.measureHeartRateSpot() - // Phase 1 — warm up: the manual stream emits bpm 0 for ~25s before a real reading. Poll the full - // window for the first reading *of this measurement* (not a stale prior value). Warm-up zeros are - // dropped by the decoder, so `hrNoReadingReported` only trips on a genuine error (worn wrong). - _ = await pollForValue( - window: hrMeasureSeconds, - value: { self.measurementReceivedReading ? self.latestHRValue : nil }, - abort: { self.hrNoReadingReported || self.spot.isRejected(token) } - ) - // Phase 2 — settle: keep reading briefly so the reported value is stable, not a first jump. - var result = measurementReceivedReading ? latestHRValue : nil - if result != nil { - for _ in 0..<(hrSettleSeconds * 2) { // 0.5s granularity - try? await Task.sleep(nanoseconds: 500_000_000) - if let v = latestHRValue { result = v } // latest stable sample - } + + // Sample the full window in 0.5s steps: `handle(_:)` discards everything inside the warm-up and + // collects the rest into `hrSamples`. We break out early only where continuing is pointless — + // and each of those is an abort, not a short-but-usable reading, so none of them report a value. + var aborted = false + let steps = Int(hrMeasureSeconds) * 2 // 0.5s granularity + for _ in 0.. Int?, abort: () -> Bool) async -> Int? { let steps = Int(window) * 2 // 0.5s granularity for _ in 0.. Reading { + func latest(_ k: MeasurementKind) -> Int? { + MetricsService.fetchMeasurements(context) + .first { $0.kind == k } + .map { Int($0.value) } + } + + switch kind { + case .hr, .spo2, .hrv: + let metric: MeasurementKind = kind == .hr ? .heartRate : (kind == .spo2 ? .spo2 : .hrv) + MetricsService.insertMockMeasurement(kind: metric, context: context) + return Reading(value: latest(metric)) + + case .bloodPressure: + // BP is stored as two rows so each trends independently — mock both. + MetricsService.insertMockMeasurement(kind: .bloodPressureSystolic, context: context) + MetricsService.insertMockMeasurement(kind: .bloodPressureDiastolic, context: context) + return Reading(value: latest(.bloodPressureSystolic), secondary: latest(.bloodPressureDiastolic)) + + case .vitals: + // Mock the metrics the jring's combined packet actually carries. + for metric in [MeasurementKind.heartRate, .spo2, .bloodPressureSystolic, .bloodPressureDiastolic] { + MetricsService.insertMockMeasurement(kind: metric, context: context) + } + var bloodPressure: RingSyncCoordinator.BloodPressureReading? + if let systolic = latest(.bloodPressureSystolic), let diastolic = latest(.bloodPressureDiastolic) { + bloodPressure = .init(systolic: systolic, diastolic: diastolic) + } + return Reading(vitals: RingSyncCoordinator.VitalsReading( + heartRate: latest(.heartRate), + bloodPressure: bloodPressure, + spo2: latest(.spo2) + )) + } + } +} diff --git a/PulseLoop/Views/MeasurementKindPresentation.swift b/PulseLoop/Views/MeasurementKindPresentation.swift new file mode 100644 index 0000000..7bf397d --- /dev/null +++ b/PulseLoop/Views/MeasurementKindPresentation.swift @@ -0,0 +1,109 @@ +import SwiftUI + +/// Everything the measurement sheet renders that depends only on *which* metric is being measured. +/// +/// Kept apart from `MeasurementSheet` so the sheet is about the measurement's lifecycle — the stages, +/// the clock, the ring — and adding a sixth metric is a matter of answering these questions once, +/// rather than threading a new case through the middle of the state machine. +extension MeasurementSheet.Kind { + var title: String { + switch self { + case .hr: return "Heart Rate" + case .spo2: return "Blood Oxygen" + case .hrv: return "Heart Rate Variability" + case .bloodPressure: return "Blood Pressure" + case .vitals: return "Vitals" + } + } + + var unit: String { + switch self { + case .hr, .vitals: return "bpm" + case .spo2: return "%" + case .hrv: return "ms" + case .bloodPressure: return "mmHg" + } + } + + var tint: Color { + switch self { + case .hr, .vitals: return PulseColors.heartRate + case .spo2: return PulseColors.spo2 + case .hrv: return PulseColors.hrv + case .bloodPressure: return PulseColors.bloodPressure + } + } + + var symbolName: String { + switch self { + case .hr, .vitals: return "heart.fill" + case .spo2: return "lungs.fill" + case .hrv: return "waveform.path.ecg" + case .bloodPressure: return "heart.text.square" + } + } + + /// Shown while the sheet holds before arming — what the user should do to make the reading work. + var instruction: String { + switch self { + case .hr: return "Rest your wrist on a flat surface and keep still." + case .spo2: return "Keep the ring pressed firmly to your skin and breathe normally." + case .hrv: return "Sit still and breathe normally — HRV needs a steady stretch of beats." + case .bloodPressure: return "Sit upright, rest your hand at heart height, and stay still." + case .vitals: return "Sit upright, rest your hand at heart height, and stay still. This takes about a minute." + } + } + + /// Shown under the ring while the measurement runs. + var workingCopy: String { + switch self { + case .hr: return "Finding your pulse…" + case .spo2: return "Reading blood oxygen…" + case .hrv: return "Reading heart rate variability…" + case .bloodPressure: return "Measuring blood pressure…" + case .vitals: return "Measuring your vitals…" + } + } + + /// What went wrong, phrased as the thing the user can actually do about it. + var failureMessage: String { + switch self { + case .hr: + // HR's failure mode is now "we read you, but the numbers never agreed" — see + // `HRSampleWindow`. Stillness is the lever the user has, so the copy asks for stillness. + return "Couldn't get a steady heart-rate reading. Keep the ring snug and your hand still, then try again." + case .spo2: + return "Couldn't get a blood-oxygen reading. Wear the ring snugly and keep still, then try again." + case .hrv: + return "Couldn't get an HRV reading. Wear the ring snugly, keep still, and try again." + case .bloodPressure: + return "Couldn't get a blood-pressure reading. Wear the ring snugly, rest your hand at heart height, and try again." + case .vitals: + return "Couldn't get a reading. Wear the ring snugly, keep still, and try again." + } + } + + /// SpO₂ breathes rather than beats — its ambient pulse runs at a slower cadence. + var slowBreathing: Bool { self == .spo2 } +} + +/// The header eyebrow — the one place the sheet names the stage the user is in. +extension MeasurementSheet.Stage { + func eyebrow(isCombinedSweep: Bool) -> String { + switch self { + case .preparing: return "GET READY" + case .searching, .locking: return "MEASURING" + case .result: return isCombinedSweep ? "RESULTS" : "COMPLETE" + case .error: return "ISSUE" + } + } + + func eyebrowColor(tint: Color) -> Color { + switch self { + case .preparing: return PulseColors.textMuted + case .searching, .locking: return tint + case .result: return PulseColors.success + case .error: return PulseColors.danger + } + } +} diff --git a/PulseLoop/Views/MeasurementModal.swift b/PulseLoop/Views/MeasurementModal.swift index 2767915..c48fbd3 100644 --- a/PulseLoop/Views/MeasurementModal.swift +++ b/PulseLoop/Views/MeasurementModal.swift @@ -1,62 +1,91 @@ import SwiftUI -import SwiftData +import Combine +#if canImport(UIKit) +import UIKit +#endif /// Live measurement sheet ported from `frontend/src/components/measurement/MeasurementModal.tsx`. -/// Drives the existing `RingSyncCoordinator` measure flow when the ring is connected; otherwise -/// simulates a reading and saves a mock `Measurement` so the demo charts update. +/// Drives the `RingSyncCoordinator` measure flow. A disconnected ring surfaces an error — it never +/// fabricates a reading, because a fake vital saved to history is indistinguishable from a real one. +/// +/// THE RING IS ONE CLOCK, and it only ever tells the truth about time: +/// +/// * `.hr` is the single measurement with a genuinely fixed duration — it samples its whole window +/// (see `RingSyncCoordinator.measureHR`), so the ring fills 0→full while a countdown ticks to zero. +/// The window is read from the coordinator, never copied, so the fill can't desync from the measure. +/// * every other kind returns the instant its value lands, at a time nobody can predict. They get an +/// indeterminate sweep and a count-*up*: an honest "still working", not a promise we can't keep. +/// +/// Both are derived from wall-clock time via `TimelineView(.animation)`, so they keep moving under +/// Reduce Motion (a TimelineView tick is not accessibility "motion"). Reduce Motion gates only the +/// decorative heartbeat and beat-rings; it never freezes a value the user is waiting on. struct MeasurementSheet: View { /// `.vitals` is one sweep that returns every metric the ring computes (jring's `0x24` packet); /// the rest are single-metric spot readings on devices that measure one thing at a time. enum Kind: Hashable { case hr, spo2, hrv, bloodPressure, vitals } - enum Phase { case preparing, measuring, result, error } + + /// The measurement lifecycle. `searching` is the working state — counting down (HR) or up (rest). + enum Stage { case preparing, searching, locking, result, error } let kind: Kind @Environment(\.dismiss) private var dismiss + /// Only the demo path writes through this — a real reading is persisted by the coordinator's event + /// subscriber as its samples arrive. @Environment(\.modelContext) private var modelContext @Environment(RingBLEClient.self) private var ble @Environment(RingSyncCoordinator.self) private var coordinator + @Environment(\.accessibilityReduceMotion) private var reduceMotion - @State private var phase: Phase = .preparing + // MARK: State + @State private var stage: Stage = .preparing @State private var value: Int? /// Diastolic, for blood pressure — the only single reading that is a pair. @State private var secondaryValue: Int? /// Populated for `.vitals`: every metric the sweep returned. @State private var vitals: RingSyncCoordinator.VitalsReading? - @State private var animate = false + /// Wall-clock anchor for the ring. `TimelineView` derives fill + elapsed/remaining from it. + @State private var measureStart: Date? + /// Bumping this cancels the in-flight `.task` and starts a fresh run — that's the whole retry + /// mechanism, and why there's no hand-rolled `Task` handle to leak or double-cancel. + @State private var attempt = 0 + /// Coarse elapsed clock (0.5s), used only for copy swaps and the throttled VoiceOver bucket — + /// never for the ring, which is time-derived. + @State private var elapsed: TimeInterval = 0 + @State private var announcedStillWorking = false + /// One-shot so the "reading acquired" haptic fires once per measurement. + @State private var acquiredHaptic = false + @AccessibilityFocusState private var errorFocus: Bool - private var color: Color { - switch kind { - case .hr, .vitals: return PulseColors.heartRate - case .spo2: return PulseColors.spo2 - case .hrv: return PulseColors.hrv - case .bloodPressure: return PulseColors.bloodPressure - } - } - private var name: String { - switch kind { - case .hr: return "Heart Rate" - case .spo2: return "Blood Oxygen" - case .hrv: return "Heart Rate Variability" - case .bloodPressure: return "Blood Pressure" - case .vitals: return "Vitals" - } - } - private var unit: String { - switch kind { - case .hr, .vitals: return "bpm" - case .spo2: return "%" - case .hrv: return "ms" - case .bloodPressure: return "mmHg" - } - } - private var instruction: String { - switch kind { - case .hr: return "Keep your hand still and rest your wrist on a flat surface." - case .spo2: return "Breathe normally. Keep the sensor pressed firmly to your skin." - case .hrv: return "Sit still and breathe normally — HRV needs a steady stretch of beats." - case .bloodPressure: return "Sit upright, rest your hand at heart height, and stay still." - case .vitals: return "Sit upright, rest your hand at heart height, and stay still. This takes about a minute." - } + // MARK: Animation drivers (the ONLY Reduce-Motion gates) + @State private var ambientPulse = false + @State private var beatPulse = false + @State private var ringColor: Color = .clear + @State private var resultBounce: CGFloat = 1 + + #if canImport(UIKit) + private let lightImpact = UIImpactFeedbackGenerator(style: .light) + private let softImpact = UIImpactFeedbackGenerator(style: .soft) + private let notify = UINotificationFeedbackGenerator() + #endif + + private let elapsedTimer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect() + + // MARK: - Per-kind tokens + // + // `Kind` answers everything that depends only on which metric this is — see + // `MeasurementKindPresentation`. The sheet keeps only what depends on the measurement's *state*. + + private var color: Color { kind.tint } + private var name: String { kind.title } + private var unit: String { kind.unit } + private var instruction: String { kind.instruction } + + /// The one measurement with a fixed, known duration — so the only one we can honestly count down. + /// Sourced from the coordinator: copying the literal is how the ring and the measurement desync. + /// Nil in demo mode too — no 30s window is running there, so a countdown would be pure theatre. + private var countdownWindow: Double? { + guard kind == .hr, ble.state == .connected else { return nil } + return Double(coordinator.hrMeasureSeconds) } /// The big number in the ring. Blood pressure shows the systolic/diastolic pair. @@ -66,66 +95,26 @@ struct MeasurementSheet: View { return "\(value)" } - /// One row per metric the sweep actually produced — the ring leaves the rest at zero. - private var vitalTiles: [VitalTile] { - guard let v = vitals else { return [] } - var tiles: [VitalTile] = [] - if let hr = v.heartRate { - tiles.append(.init(name: "Heart Rate", value: "\(hr)", unit: "bpm", icon: "heart.fill", tint: PulseColors.heartRate)) - } - if let bp = v.bloodPressure { - tiles.append(.init(name: "Blood Pressure", value: "\(bp.systolic)/\(bp.diastolic)", unit: "mmHg", icon: "heart.text.square", tint: PulseColors.bloodPressure)) - } - if let spo2 = v.spo2 { - tiles.append(.init(name: "Blood Oxygen", value: "\(spo2)", unit: "%", icon: "lungs.fill", tint: PulseColors.spo2)) - } - if let fatigue = v.fatigue { - tiles.append(.init(name: "Fatigue", value: "\(fatigue)", unit: "", icon: "battery.25", tint: PulseColors.warning)) - } - if let stress = v.stress { - tiles.append(.init(name: "Stress", value: "\(stress)", unit: "", icon: "bolt.fill", tint: PulseColors.stress)) - } - if let hrv = v.hrv { - tiles.append(.init(name: "HRV", value: "\(hrv)", unit: "ms", icon: "waveform.path.ecg", tint: PulseColors.hrv)) - } - if let sugar = v.bloodSugarMgdl { - tiles.append(.init(name: "Blood Sugar", value: "\(Int(sugar.rounded()))", unit: "mg/dL", icon: "drop.fill", tint: PulseColors.bloodSugar)) - } - return tiles - } - - struct VitalTile: Identifiable { - let name: String - let value: String - let unit: String - let icon: String - let tint: Color - var id: String { name } + /// Explicit Done (vs auto-dismiss) is required under VoiceOver or Switch Control — and for the + /// combined sweep, which has several numbers to read. + private var needsExplicitDone: Bool { + if kind == .vitals { return true } + #if canImport(UIKit) + return UIAccessibility.isVoiceOverRunning || UIAccessibility.isSwitchControlRunning + #else + return false + #endif } var body: some View { VStack(spacing: 0) { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 2) { - Text(phase == .result ? "RESULTS" : "MEASURING") - .font(PulseFont.micro).tracking(1.8) - .foregroundStyle(PulseColors.textMuted) - Text(name).font(PulseFont.title3).foregroundStyle(PulseColors.textPrimary) - } - Spacer() - Button(closeTitle) { dismiss() } - .font(PulseFont.footnote.weight(.regular)) - .foregroundStyle(PulseColors.textSecondary) - .padding(.horizontal, 12).padding(.vertical, 6) - .background(PulseColors.card, in: Capsule()) - .overlay(Capsule().stroke(PulseColors.borderSubtle, lineWidth: 1)) - } - .padding(24) + header + .padding(24) - // The combined results sit directly under the header — several cards need the height, and - // centring them pushed the first row off the top on small phones. - if phase == .result, kind == .vitals { - vitalsResults + // The combined sweep returns a whole grid rather than one number, so at result it replaces + // the ring entirely (see `VitalsResultsView`). Every other kind keeps the ring. + if stage == .result, kind == .vitals, let vitals { + VitalsResultsView(vitals: vitals) Spacer(minLength: 0) } else { measuringContent @@ -133,166 +122,158 @@ struct MeasurementSheet: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(PulseColors.background.ignoresSafeArea()) - .task { await run() } - .onAppear { animate = true } + .pulseGlassContainer() + .accessibilityElement(children: .contain) + .accessibilityAddTraits(.isModal) + .accessibilityLabel("Measuring \(name)") + .overlay(alignment: .bottom) { statusElement } + .onReceive(elapsedTimer) { _ in tickElapsed() } + // A real bpm landed mid-window (HR runs the full window regardless, so this doesn't advance the + // stage — it just gives the moment a tactile beat and lets the live value take over the centre). + .onChange(of: coordinator.measurementReceivedReading) { _, received in + guard kind == .hr, received, !acquiredHaptic else { return } + acquiredHaptic = true + #if canImport(UIKit) + softImpact.impactOccurred(intensity: 0.7) + #endif + } + .task(id: attempt) { await run() } + } + + // MARK: - Header + + private var header: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2) { + Text(stage.eyebrow(isCombinedSweep: kind == .vitals)) + .font(PulseFont.micro).tracking(1.8) + .foregroundStyle(stage.eyebrowColor(tint: color)) + .contentTransition(.opacity) + Text(name).font(PulseFont.title3).foregroundStyle(PulseColors.textPrimary) + } + Spacer() + controlButton + } } - /// The pulsing ring (and the single-metric result), vertically centred. + /// Cancel while working, Done at result. + private var controlButton: some View { + Group { + switch stage { + case .preparing, .searching, .locking: + // Dismissing tears down the `.task`, which cancels the measurement and stops the ring's + // stream — no separate cancel plumbing to keep in sync. + Button("Cancel") { dismiss() } + .accessibilityHint("Stops measuring and closes without saving.") + case .result: + Button("Done") { dismiss() } + case .error: + EmptyView() + } + } + .font(PulseFont.footnote) + .foregroundStyle(PulseColors.textPrimary) + .padding(.horizontal, 14) + .frame(minWidth: 44, minHeight: 44) + .contentShape(Capsule()) + .pulseGlass(Capsule(), interactive: true) + } + + // MARK: - Measuring content (ring + copy), vertically centred + @ViewBuilder private var measuringContent: some View { VStack(spacing: 0) { Spacer() - if phase == .error { + if stage == .error { errorState + .transition(.opacity) } else { - ZStack { - ForEach(0..<3) { i in - Circle() - .stroke(color.opacity(0.5), lineWidth: 2) - .frame(width: 200, height: 200) - .scaleEffect(animate ? 1.15 : 0.85) - .opacity(animate ? 0 : 0.6) - .animation(.easeOut(duration: 1.6).repeatForever(autoreverses: false).delay(Double(i) * 0.5), value: animate) - } - Circle().fill(color.opacity(0.12)).frame(width: 220, height: 220) - VStack(spacing: 4) { - if let readingText, phase != .preparing { - // `numberHero`, not `nano` (9pt) — this is the sheet's centrepiece. The - // scale factor keeps a two-part reading like "120/80" on one line. - Text(readingText).font(PulseFont.numberHero).monospacedDigit() - .lineLimit(1) - .minimumScaleFactor(0.6) - .foregroundStyle(PulseColors.textPrimary) - Text(unit.uppercased()).font(PulseFont.caption.weight(.regular)).tracking(1.4).foregroundStyle(PulseColors.textMuted) - } else { - Text(phase == .preparing ? "READY" : "MEASURING") - .font(PulseFont.subheadline).tracking(1.8) - .foregroundStyle(PulseColors.textMuted) - } - } - // Bound the reading to the inner circle so `minimumScaleFactor` has something to - // shrink against rather than overflowing the ring. - .frame(maxWidth: 190) - } - .frame(height: 240) - - Text(phaseCopy) - .font(PulseFont.subheadline.weight(.regular)) - .foregroundStyle(PulseColors.textSecondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 280) + ring + .frame(height: 260) + .accessibilityHidden(true) + + copyLine .padding(.top, 24) + .padding(.horizontal, 24) } Spacer() - if phase == .result { + if stage == .result { Text("Saved") - .font(PulseFont.subheadline) + .font(PulseFont.subheadline.weight(.semibold)) .foregroundStyle(PulseColors.success) .frame(maxWidth: .infinity) .padding(.vertical, 14) - .background(PulseColors.success.opacity(0.10)) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(PulseColors.success.opacity(0.3), lineWidth: 1)) + .pulseMaterialize(RoundedRectangle(cornerRadius: 16, style: .continuous)) .padding(.horizontal, 24) .padding(.bottom, 28) + .accessibilityHidden(true) } } } - /// The combined sweep stays open until dismissed — there's more than one number to read. - private var closeTitle: String { - if phase == .measuring { return "Finish" } - if phase == .result, kind == .vitals { return "Done" } - return "Cancel" - } + // MARK: - The ring - /// Result view for the combined sweep: one card per metric the ring actually returned. Sits at the - /// top of the sheet — the confirmation is a compact inline row so the cards get the vertical space. - private var vitalsResults: some View { - VStack(alignment: .leading, spacing: 20) { - HStack(spacing: 10) { - Image(systemName: "checkmark") - .font(PulseFont.footnote.weight(.bold)) - .foregroundStyle(PulseColors.success) - .frame(width: 30, height: 30) - .background(PulseColors.success.opacity(0.10), in: Circle()) - .overlay(Circle().stroke(PulseColors.success.opacity(0.3), lineWidth: 1)) - Text("Reading complete") - .font(PulseFont.bodyEmphasis) - .foregroundStyle(PulseColors.textPrimary) - Spacer(minLength: 0) - } + private var ring: some View { + MeasurementRingView( + stage: stage, + tint: color, + symbolName: kind.symbolName, + unit: unit.uppercased(), + countdownWindow: countdownWindow, + measureStart: measureStart, + liveValue: liveValue, + resultText: readingText, + slowBreathing: kind.slowBreathing, + ringColor: ringColor, + resultBounce: resultBounce, + ambientPulse: ambientPulse, + beatPulse: beatPulse + ) + } - ScrollView { - LazyVGrid(columns: [GridItem(.flexible(), spacing: 14), GridItem(.flexible(), spacing: 14)], spacing: 14) { - ForEach(vitalTiles) { tile in - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 7) { - Image(systemName: tile.icon).font(PulseFont.footnote).foregroundStyle(tile.tint) - Text(tile.name) - .font(PulseFont.caption) - .foregroundStyle(PulseColors.textMuted) - .lineLimit(1).minimumScaleFactor(0.75) - } - HStack(alignment: .firstTextBaseline, spacing: 4) { - Text(tile.value) - .font(PulseFont.numberXL).monospacedDigit() - .foregroundStyle(PulseColors.textPrimary) - .lineLimit(1).minimumScaleFactor(0.5) - if !tile.unit.isEmpty { - Text(tile.unit).font(PulseFont.caption).foregroundStyle(PulseColors.textMuted) - } - } - } - .padding(18) - .frame(maxWidth: .infinity, minHeight: 108, alignment: .leading) - .background(PulseColors.card, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: 20, style: .continuous).stroke(PulseColors.borderSubtle, lineWidth: 1)) - } - } - .padding(.bottom, 24) - } - .scrollBounceBehavior(.basedOnSize) + /// A value worth putting in the centre of the ring *during* the measurement. Only HR has one: it + /// streams a real bpm mid-window. The rest have nothing trustworthy to show until they land — SpO₂ + /// in particular reports progress percentages through the same channel as its result, so a "live" + /// SpO₂ number would sometimes just be a progress bar wearing a percent sign. + private var liveValue: String? { + guard kind == .hr, coordinator.measurementReceivedReading, let bpm = coordinator.latestHRValue else { + return nil } - .padding(.horizontal, 24) + return "\(bpm)" } - private var errorMessage: String { - guard ble.state == .connected else { - return "Your ring isn't connected. Reconnect it and try again." - } - switch kind { - case .hr: - return "Couldn't get a heart-rate reading. Make sure the ring is snug and worn on your finger, then try again." - case .spo2: - return "Couldn't get a blood-oxygen reading. Wear the ring snugly and keep still, then try again." - case .hrv: - return "Couldn't get an HRV reading. Wear the ring snugly, keep still, and try again." - case .bloodPressure: - return "Couldn't get a blood-pressure reading. Wear the ring snugly, rest your hand at heart height, and try again." - case .vitals: - return "Couldn't get a reading. Wear the ring snugly, keep still, and try again." - } + // MARK: - Copy under the ring + + private var copyLine: some View { + Text(copyText) + .font(PulseFont.subheadline.weight(.regular)) + .foregroundStyle(PulseColors.textSecondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 280) + .contentTransition(.opacity) } - private var phaseCopy: String { - switch phase { + private var copyText: String { + switch stage { case .preparing: return instruction - case .measuring: - switch kind { - case .spo2: return "Measuring SpO₂… keep your hand still." - case .bloodPressure: return "Measuring blood pressure… stay still." - case .vitals: return "Measuring your vitals… stay still." - default: return "Measuring… stay still." + case .searching: + if elapsed >= 20 { return "Just a few seconds more…" } + if elapsed >= 12 { + return kind == .hr ? "Still working — hold steady." : "Still working — stay still." } - case .result: return "Reading saved." + return kind.workingCopy + case .locking: return kind == .hr ? "Locking it in…" : "Locking in your reading…" + case .result: return "Saved to your history." case .error: return "" } } + // MARK: - Error + private var errorState: some View { VStack(spacing: 16) { Image(systemName: "exclamationmark") @@ -301,21 +282,182 @@ struct MeasurementSheet: View { .frame(width: 80, height: 80) .background(PulseColors.danger.opacity(0.10), in: Circle()) .overlay(Circle().stroke(PulseColors.danger.opacity(0.3), lineWidth: 1)) + .accessibilityHidden(true) + Text(errorMessage) - .font(PulseFont.subheadline.weight(.regular)).foregroundStyle(PulseColors.textSecondary) - .multilineTextAlignment(.center).frame(maxWidth: 280) - Button("Close") { dismiss() } - .font(PulseFont.subheadline.weight(.semibold)).foregroundStyle(.white) - .padding(.horizontal, 20).padding(.vertical, 10) - .background(PulseColors.accent, in: Capsule()) + .font(PulseFont.subheadline.weight(.regular)) + .foregroundStyle(PulseColors.textSecondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 280) + + HStack(spacing: 12) { + Button("Try again") { retry() } + .pulseGlassButton(prominent: true) + .accessibilityFocused($errorFocus) + + Button("Close") { dismiss() } + .font(PulseFont.subheadline.weight(.semibold)) + .foregroundStyle(PulseColors.textPrimary) + .padding(.horizontal, 20) + .frame(minWidth: 44, minHeight: 44) + .contentShape(Capsule()) + .pulseGlass(Capsule(), interactive: true) + } + .padding(.top, 4) } } + private var errorMessage: String { + guard ble.state == .connected else { + return "Your ring isn't connected. Reconnect it and try again." + } + return kind.failureMessage + } + + // MARK: - VoiceOver status + + private var statusElement: some View { + Color.clear + .frame(width: 1, height: 1) + .accessibilityElement() + .accessibilityLabel("\(name) measurement") + .accessibilityValue(statusValue) + .accessibilityAddTraits(isBusy ? .updatesFrequently : []) + } + + private var isBusy: Bool { stage == .preparing || stage == .searching || stage == .locking } + + /// Bucketed to ~10s while working — never a per-second string, which VoiceOver would spam. + private var statusValue: String { + switch stage { + case .preparing: return "Preparing" + case .searching: + guard let window = countdownWindow else { return "Measuring \(name)" } + let bucket = max(0, Int((window - elapsed) / 10) * 10) + return bucket > 0 ? "Measuring \(name), about \(bucket) seconds" : "Measuring \(name)" + case .locking: return "Locking in your reading" + case .result: + if kind == .vitals { + let count = vitals.map { VitalsResultsView.tiles(for: $0).count } ?? 0 + return "Reading complete. \(count) results. Saved." + } + guard let readingText else { return "Complete" } + return "\(readingText) \(unit). Saved." + case .error: return errorMessage + } + } + + // MARK: - Stage transitions + + private func enterSearching() { + withAnimation(.easeInOut(duration: 0.25)) { stage = .searching } + measureStart = Date() + elapsed = 0 + announcedStillWorking = false + ambientPulse = true + beatPulse = true + #if canImport(UIKit) + lightImpact.impactOccurred() + #endif + announce("Measuring \(name)") + } + + private func enterLocking() { + guard stage == .searching else { return } + withAnimation(reduceMotion ? .none : PulseMotion.bouncy) { stage = .locking } + } + + private func enterResult() { + withAnimation(.easeInOut(duration: 0.25)) { stage = .result } + withAnimation(.easeInOut(duration: 0.3)) { ringColor = PulseColors.success } + #if canImport(UIKit) + notify.notificationOccurred(.success) + #endif + if !reduceMotion { + withAnimation(.spring(response: 0.35, dampingFraction: 0.6)) { resultBounce = 1.06 } + withAnimation(.spring(response: 0.35, dampingFraction: 0.6).delay(0.18)) { resultBounce = 1.0 } + } + announce(statusValue) + } + + private func enterError() { + withAnimation(.easeInOut(duration: 0.25)) { stage = .error } + #if canImport(UIKit) + notify.notificationOccurred(.error) + #endif + announce(errorMessage) + Task { @MainActor in + try? await Task.sleep(for: .seconds(0.4)) + errorFocus = true + } + } + + private func tickElapsed() { + guard stage == .searching, let start = measureStart else { return } + elapsed = Date().timeIntervalSince(start) + if !announcedStillWorking, elapsed >= 12 { + announcedStillWorking = true + announce("Still working, keep still") + } + } + + private func announce(_ message: String) { + #if canImport(UIKit) + guard UIAccessibility.isVoiceOverRunning else { return } + AccessibilityNotification.Announcement(message).post() + #endif + } + + /// Bumping `attempt` cancels the running `.task` (which stops the ring's stream) and starts a + /// clean one. + private func retry() { + stage = .preparing + value = nil + secondaryValue = nil + vitals = nil + measureStart = nil + elapsed = 0 + ringColor = .clear + ambientPulse = false + beatPulse = false + resultBounce = 1 + acquiredHaptic = false + announcedStillWorking = false + attempt += 1 + } + + // MARK: - Driver + @MainActor private func run() async { - phase = .preparing - try? await Task.sleep(for: .seconds(1.2)) - phase = .measuring + #if canImport(UIKit) + lightImpact.prepare() + softImpact.prepare() + notify.prepare() + #endif + + stage = .preparing + try? await Task.sleep(for: .seconds(3.0)) // instruction hold + if Task.isCancelled { return } + + // A ring that isn't connected splits two ways, and conflating them was the bug: the sheet used + // to fabricate a reading for BOTH. + // + // * Nobody has ever paired a ring → this is the "Explore without ring" visitor. The demo + // reading is the point, and it is tagged `source: .mock`, which the charts and the coach + // both key off (`isDemo`). Keep it. + // * A ring IS paired, it just isn't connected right now → the user believes they are taking a + // real measurement. Handing them an invented number here is indefensible. Error instead. + guard ble.state == .connected else { + if MetricsService.fetchDevices(modelContext).isEmpty { + await runDemoMeasurement() + } else { + enterError() + } + return + } + + enterSearching() if kind == .vitals { await runCombinedVitals() @@ -323,77 +465,78 @@ struct MeasurementSheet: View { } let result: Int? - if ble.state == .connected { - switch kind { - case .hr: result = await coordinator.measureHR() - case .spo2: result = await coordinator.measureSpO2() - case .hrv: result = await coordinator.measureHRV() - case .bloodPressure: - let reading = await coordinator.measureBloodPressure() - secondaryValue = reading?.diastolic - result = reading?.systolic - case .vitals: return // handled by `runCombinedVitals` above - } - } else { - // Demo mode: simulate the measurement window, then persist a mock reading. - try? await Task.sleep(for: .seconds(kind == .hr ? 2.2 : 3.0)) - if kind == .bloodPressure { - // BP is stored as two rows so each trends independently — mock both. - MetricsService.insertMockMeasurement(kind: .bloodPressureSystolic, context: modelContext) - MetricsService.insertMockMeasurement(kind: .bloodPressureDiastolic, context: modelContext) - let rows = MetricsService.fetchMeasurements(modelContext) - secondaryValue = rows.first { $0.kind == .bloodPressureDiastolic }.map { Int($0.value) } - result = rows.first { $0.kind == .bloodPressureSystolic }.map { Int($0.value) } - } else { - let measurementKind: MeasurementKind = { - switch kind { - case .hr: return .heartRate - case .spo2: return .spo2 - case .hrv: return .hrv - // Both handled before reaching here (BP just above, vitals in `runCombinedVitals`). - case .bloodPressure, .vitals: return .bloodPressureSystolic - } - }() - MetricsService.insertMockMeasurement(kind: measurementKind, context: modelContext) - result = MetricsService.fetchMeasurements(modelContext) - .first(where: { $0.kind == measurementKind }) - .map { Int($0.value) } - } + switch kind { + case .hr: result = await coordinator.measureHR() + case .spo2: result = await coordinator.measureSpO2() + case .hrv: result = await coordinator.measureHRV() + case .bloodPressure: + let reading = await coordinator.measureBloodPressure() + secondaryValue = reading?.diastolic + result = reading?.systolic + case .vitals: return // handled above } - guard let result else { phase = .error; return } - // A BP reading without its diastolic half is not a usable reading. - if kind == .bloodPressure, secondaryValue == nil { phase = .error; return } - value = result - phase = .result - try? await Task.sleep(for: .seconds(1.3)) - dismiss() + if Task.isCancelled { return } + + await reveal(result) } - /// One sweep, every metric. Unlike the single-metric flows this does **not** auto-dismiss — there - /// are several numbers to read, so the sheet waits for "Done". + /// One sweep, every metric. @MainActor private func runCombinedVitals() async { - if ble.state == .connected { - vitals = await coordinator.measureVitals() + vitals = await coordinator.measureVitals() + if Task.isCancelled { return } + await revealVitals() + } + + /// The "Explore without ring" path — see `MeasurementDemoData`, which owns the seeding and the + /// `source: .mock` tagging. The sheet just runs its usual window so the demo feels like the real one. + @MainActor + private func runDemoMeasurement() async { + enterSearching() + try? await Task.sleep(for: .seconds(kind == .hr ? 2.2 : 3.0)) + if Task.isCancelled { return } + + let demo = MeasurementDemoData.seed(kind, context: modelContext) + secondaryValue = demo.secondary + vitals = demo.vitals + if kind == .vitals { + await revealVitals() } else { - // Demo mode: mock the metrics the jring's combined packet actually carries. - try? await Task.sleep(for: .seconds(3.0)) - for kind in [MeasurementKind.heartRate, .spo2, .bloodPressureSystolic, .bloodPressureDiastolic] { - MetricsService.insertMockMeasurement(kind: kind, context: modelContext) - } - let rows = MetricsService.fetchMeasurements(modelContext) - func latest(_ k: MeasurementKind) -> Int? { rows.first { $0.kind == k }.map { Int($0.value) } } - var bloodPressure: RingSyncCoordinator.BloodPressureReading? - if let systolic = latest(.bloodPressureSystolic), let diastolic = latest(.bloodPressureDiastolic) { - bloodPressure = .init(systolic: systolic, diastolic: diastolic) - } - vitals = RingSyncCoordinator.VitalsReading( - heartRate: latest(.heartRate), - bloodPressure: bloodPressure, - spo2: latest(.spo2) - ) + await reveal(demo.value) } - guard let vitals, !vitals.isEmpty else { phase = .error; return } - phase = .result + } + + /// The combined sweep succeeds on *any* metric — the ring populates whatever it can, so this never + /// hangs its success on one number the way a single-metric reading does. Always waits for "Done". + @MainActor + private func revealVitals() async { + if Task.isCancelled { return } + guard let vitals, !vitals.isEmpty else { enterError(); return } + enterLocking() + try? await Task.sleep(for: .seconds(0.5)) + if Task.isCancelled { return } + enterResult() + } + + /// Land a finished single-metric reading: fill the ring, show the number, and (unless the user is + /// on VoiceOver) close the sheet behind them. + @MainActor + private func reveal(_ result: Int?) async { + if Task.isCancelled { return } + guard let result else { enterError(); return } + // A BP reading without its diastolic half is not a usable reading. + if kind == .bloodPressure, secondaryValue == nil { enterError(); return } + + enterLocking() + value = result + // Let the ring visibly finish its fill before the number lands. + try? await Task.sleep(for: .seconds(0.5)) + if Task.isCancelled { return } + enterResult() + + guard !needsExplicitDone else { return } + try? await Task.sleep(for: .seconds(1.3)) + if Task.isCancelled { return } + dismiss() } } diff --git a/PulseLoop/Views/MeasurementRingView.swift b/PulseLoop/Views/MeasurementRingView.swift new file mode 100644 index 0000000..7517285 --- /dev/null +++ b/PulseLoop/Views/MeasurementRingView.swift @@ -0,0 +1,231 @@ +import SwiftUI + +/// The measurement sheet's ring: one circle that is both the progress indicator and the readout. +/// +/// It is driven entirely by wall-clock time through `TimelineView(.animation)`, and that is the whole +/// point — the fill and the number advance on their own, so they keep moving under Reduce Motion. (The +/// arc this replaces was animated by a state flag that never flipped under Reduce Motion, so it froze +/// at a hardcoded half-turn and sat there for the length of the measurement.) +/// +/// Reduce Motion gates only the decorative heartbeat and beat-rings. It never freezes, or substitutes a +/// literal for, a value the user is waiting on. +struct MeasurementRingView: View { + let stage: MeasurementSheet.Stage + let tint: Color + let symbolName: String + let unit: String + /// The measurement's fixed duration, when it *has* one. Non-nil → a real countdown; nil → an + /// indeterminate sweep, because we don't know when this measurement will land and won't pretend to. + let countdownWindow: Double? + let measureStart: Date? + /// A live value to promote into the centre mid-measurement (HR's bpm, once a real one is on the + /// wire). Nil for measurements that have nothing trustworthy to show until they finish. + let liveValue: String? + /// The settled reading, shown at `.result`. Blood pressure passes its pair here ("120/80"). + let resultText: String? + /// SpO₂ breathes rather than beats. + let slowBreathing: Bool + + let ringColor: Color + let resultBounce: CGFloat + let ambientPulse: Bool + let beatPulse: Bool + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.colorSchemeContrast) private var contrast + + @ScaledMetric(relativeTo: .largeTitle) private var timerSize: CGFloat = 56 + + private var trackWidth: CGFloat { contrast == .increased ? 7 : 5 } + + /// The success recolour at result, once it has been applied. + private func tinted(_ fallback: Color) -> Color { + ringColor == .clear ? fallback : ringColor + } + + var body: some View { + TimelineView(.animation) { context in + let elapsed = measureStart.map { context.date.timeIntervalSince($0) } ?? 0 + + ZStack { + if !reduceMotion, stage == .searching || stage == .locking { + beatRings + } + + // Ambient heartbeat / breathing fill. Decorative, so Reduce-Motion gated. + Circle() + .fill(tinted(tint).opacity(0.10)) + .frame(width: 220, height: 220) + .scaleEffect(ambientScale) + .animation(heartbeatAnimation, value: ambientPulse) + .allowsHitTesting(false) + + Circle() + .stroke(PulseColors.card, lineWidth: trackWidth) + .frame(width: 200, height: 200) + .allowsHitTesting(false) + + progressStroke(elapsed: elapsed) + centerContent(elapsed: elapsed) + } + } + } + + private var beatRings: some View { + ForEach(0..<3, id: \.self) { i in + Circle() + .stroke(tinted(tint).opacity(0.5), lineWidth: 2) + .frame(width: 200, height: 200) + .scaleEffect(beatPulse ? 1.18 : 0.9) + .opacity(beatPulse ? 0 : 0.55) + .animation( + .easeOut(duration: 1.5).repeatForever(autoreverses: false).delay(Double(i) * 0.5), + value: beatPulse + ) + .allowsHitTesting(false) + } + } + + /// Determinate: a real 0→1 fill off the measurement's own window. Indeterminate: a short arc + /// sweeping at a constant rate — motion that says "working", never a fraction that implies a finish + /// time we don't know. + private func progressStroke(elapsed: TimeInterval) -> some View { + let indeterminate = countdownWindow == nil && stage == .searching + + return Circle() + .trim(from: 0, to: indeterminate ? 0.22 : trim(elapsed: elapsed)) + .stroke(strokeColor, style: StrokeStyle(lineWidth: trackWidth, lineCap: .round)) + // When the stage flips, the trim target becomes 1.0 and this runs the fill smoothly up from + // wherever the countdown had reached. + .animation(reduceMotion ? .linear(duration: 0.6) : PulseMotion.bouncy, value: stage) + .frame(width: 200, height: 200) + .rotationEffect(.degrees(-90 + (indeterminate ? elapsed * 140 : 0))) + .allowsHitTesting(false) + } + + private func trim(elapsed: TimeInterval) -> CGFloat { + switch stage { + case .preparing: return 0 // empty track — nothing has started yet + case .searching: + guard let window = countdownWindow, window > 0 else { return 0.22 } + return CGFloat(min(max(elapsed / window, 0), 1)) + case .locking, .result: return 1 + case .error: return 0.75 + } + } + + private var strokeColor: Color { + switch stage { + case .error: return PulseColors.danger + case .result: return tinted(PulseColors.success) + default: return tint + } + } + + // MARK: - Centre + + @ViewBuilder + private func centerContent(elapsed: TimeInterval) -> some View { + switch stage { + case .preparing: + glyph + .scaleEffect(reduceMotion ? 1 : (ambientPulse ? 1.06 : 0.94)) + .animation( + reduceMotion ? nil : .easeInOut(duration: 0.85).repeatForever(autoreverses: true), + value: ambientPulse + ) + case .searching: + if let liveValue { + // A real reading is already on the wire: promote it, demote the countdown to a caption. + hero(liveValue, unit: unit, caption: "MEASURING · \(clock(remaining(elapsed: elapsed)))") + } else if countdownWindow != nil { + hero("\(remaining(elapsed: elapsed))", unit: "SECONDS", size: timerSize) + } else { + // No known finish time, so count up. An honest "still working" beats a countdown we + // would have to invent. + hero(clock(Int(elapsed)), unit: "ELAPSED") + } + case .locking: + if let liveValue { hero(liveValue, unit: unit) } else { glyph } + case .result: + if let resultText { hero(resultText, unit: unit, bounce: true) } else { glyph } + case .error: + EmptyView() + } + } + + private var glyph: some View { + Image(systemName: symbolName) + .font(PulseFont.numberL) + .foregroundStyle(tint) + } + + /// The centred number, with an optional caption above and a unit below. The scale factor keeps a + /// pair like "120/80" on one line. + private func hero( + _ text: String, + unit: String, + caption: String? = nil, + size: CGFloat? = nil, + bounce: Bool = false + ) -> some View { + VStack(spacing: 4) { + if let caption { + Text(caption) + .font(PulseFont.footnote.monospacedDigit()) + .foregroundStyle(PulseColors.textMuted) + .contentTransition(reduceMotion ? .identity : .numericText()) + } + Group { + if let size { + Text(text).font(.system(size: size, weight: .semibold, design: .rounded)) + } else { + Text(text).font(PulseFont.numberHero) + } + } + .monospacedDigit() + .foregroundStyle(PulseColors.textPrimary) + .contentTransition(reduceMotion ? .identity : .numericText()) + .lineLimit(1) + .minimumScaleFactor(0.5) + .scaleEffect(bounce ? resultBounce : 1) + + Text(unit) + .font(PulseFont.caption.weight(.regular)).tracking(1.4) + .foregroundStyle(PulseColors.textMuted) + } + // Bound the reading to the inner circle so `minimumScaleFactor` has something to shrink against + // rather than overflowing the ring. + .frame(maxWidth: 190) + } + + private func remaining(elapsed: TimeInterval) -> Int { + guard let window = countdownWindow else { return 0 } + return max(0, Int(ceil(window - elapsed))) + } + + private func clock(_ seconds: Int) -> String { + String(format: "%d:%02d", seconds / 60, seconds % 60) + } + + // MARK: - Ambient motion + + private var ambientScale: CGFloat { + guard !reduceMotion else { return 1 } + switch stage { + case .preparing, .searching: return ambientPulse ? 1.0 : 0.94 + case .locking: return ambientPulse ? 1.05 : 1.0 + default: return 1 + } + } + + private var heartbeatAnimation: Animation? { + guard !reduceMotion else { return nil } + switch stage { + case .preparing, .searching, .locking: + return .easeInOut(duration: slowBreathing ? 1.6 : 0.85).repeatForever(autoreverses: true) + default: + return nil + } + } +} diff --git a/PulseLoop/Views/VitalsResultsView.swift b/PulseLoop/Views/VitalsResultsView.swift new file mode 100644 index 0000000..ce6e9c5 --- /dev/null +++ b/PulseLoop/Views/VitalsResultsView.swift @@ -0,0 +1,111 @@ +import SwiftUI + +/// Results for the combined vitals sweep — the one measurement that returns everything the ring +/// computes at once (jring's `0x24` packet), rather than a single number. +/// +/// Lives beside `MeasurementSheet` rather than inside it: the sheet's other four kinds all resolve to +/// one reading in the middle of the ring, and this is the only one that has to lay out a whole grid. +struct VitalsResultsView: View { + let vitals: RingSyncCoordinator.VitalsReading + + struct VitalTile: Identifiable { + let name: String + let value: String + let unit: String + let icon: String + let tint: Color + var id: String { name } + } + + /// One tile per metric the sweep actually produced — the ring leaves the rest at zero. + static func tiles(for v: RingSyncCoordinator.VitalsReading) -> [VitalTile] { + var tiles: [VitalTile] = [] + if let hr = v.heartRate { + tiles.append(.init(name: "Heart Rate", value: "\(hr)", unit: "bpm", + icon: "heart.fill", tint: PulseColors.heartRate)) + } + if let bp = v.bloodPressure { + tiles.append(.init(name: "Blood Pressure", value: "\(bp.systolic)/\(bp.diastolic)", unit: "mmHg", + icon: "heart.text.square", tint: PulseColors.bloodPressure)) + } + if let spo2 = v.spo2 { + tiles.append(.init(name: "Blood Oxygen", value: "\(spo2)", unit: "%", + icon: "lungs.fill", tint: PulseColors.spo2)) + } + if let fatigue = v.fatigue { + tiles.append(.init(name: "Fatigue", value: "\(fatigue)", unit: "", + icon: "battery.25", tint: PulseColors.warning)) + } + if let stress = v.stress { + tiles.append(.init(name: "Stress", value: "\(stress)", unit: "", + icon: "bolt.fill", tint: PulseColors.stress)) + } + if let hrv = v.hrv { + tiles.append(.init(name: "HRV", value: "\(hrv)", unit: "ms", + icon: "waveform.path.ecg", tint: PulseColors.hrv)) + } + if let sugar = v.bloodSugarMgdl { + tiles.append(.init(name: "Blood Sugar", value: "\(Int(sugar.rounded()))", unit: "mg/dL", + icon: "drop.fill", tint: PulseColors.bloodSugar)) + } + return tiles + } + + /// Sits at the top of the sheet — the confirmation is a compact inline row so the cards get the + /// vertical space. Centring them pushed the first row off the top on small phones. + var body: some View { + VStack(alignment: .leading, spacing: 20) { + HStack(spacing: 10) { + Image(systemName: "checkmark") + .font(PulseFont.footnote.weight(.bold)) + .foregroundStyle(PulseColors.success) + .frame(width: 30, height: 30) + .background(PulseColors.success.opacity(0.10), in: Circle()) + .overlay(Circle().stroke(PulseColors.success.opacity(0.3), lineWidth: 1)) + Text("Reading complete") + .font(PulseFont.bodyEmphasis) + .foregroundStyle(PulseColors.textPrimary) + Spacer(minLength: 0) + } + + ScrollView { + LazyVGrid( + columns: [GridItem(.flexible(), spacing: 14), GridItem(.flexible(), spacing: 14)], + spacing: 14 + ) { + ForEach(Self.tiles(for: vitals)) { tile in + card(tile) + } + } + .padding(.bottom, 24) + } + .scrollBounceBehavior(.basedOnSize) + } + .padding(.horizontal, 24) + } + + private func card(_ tile: VitalTile) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 7) { + Image(systemName: tile.icon).font(PulseFont.footnote).foregroundStyle(tile.tint) + Text(tile.name) + .font(PulseFont.caption) + .foregroundStyle(PulseColors.textMuted) + .lineLimit(1).minimumScaleFactor(0.75) + } + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(tile.value) + .font(PulseFont.numberXL).monospacedDigit() + .foregroundStyle(PulseColors.textPrimary) + .lineLimit(1).minimumScaleFactor(0.5) + if !tile.unit.isEmpty { + Text(tile.unit).font(PulseFont.caption).foregroundStyle(PulseColors.textMuted) + } + } + } + .padding(18) + .frame(maxWidth: .infinity, minHeight: 108, alignment: .leading) + .background(PulseColors.card, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 20, style: .continuous).stroke(PulseColors.borderSubtle, lineWidth: 1)) + } +}