diff --git a/PulseLoop/App/AppTheme.swift b/PulseLoop/App/AppTheme.swift index 02d418b..f1c6488 100644 --- a/PulseLoop/App/AppTheme.swift +++ b/PulseLoop/App/AppTheme.swift @@ -16,6 +16,7 @@ enum AppRoute: Hashable { case settingsGoals case settingsVitals case settingsPrivacyData + case settingsHealth case settingsAbout case pairing case debug diff --git a/PulseLoop/Coach/Orchestration/PendingActionExecutor.swift b/PulseLoop/Coach/Orchestration/PendingActionExecutor.swift index 22c13da..f3dd4d9 100644 --- a/PulseLoop/Coach/Orchestration/PendingActionExecutor.swift +++ b/PulseLoop/Coach/Orchestration/PendingActionExecutor.swift @@ -19,12 +19,14 @@ enum PendingActionExecutor { for event in ActivityRepository.events(sessionId: id, context: context) { context.delete(event) } context.delete(session) try? context.save() + HealthSyncService.shared.triggerAutomaticSync(context: context, delaySeconds: 1.0) return "Deleted the \(typeLabel) session." case .updateActivitySession: apply(action.updates, to: session) session.updatedAt = Date() try? context.save() + HealthSyncService.shared.triggerAutomaticSync(context: context, delaySeconds: 1.0) return "Updated the \(typeLabel) session." } } diff --git a/PulseLoop/Coach/Tools/ActionTools.swift b/PulseLoop/Coach/Tools/ActionTools.swift index 29e6362..b0a9ed8 100644 --- a/PulseLoop/Coach/Tools/ActionTools.swift +++ b/PulseLoop/Coach/Tools/ActionTools.swift @@ -12,7 +12,7 @@ enum ActionTools { } static var measurementTools: [AnyCoachTool] { [triggerMeasurement] } - private static let activityEnum = ["walk", "run", "cycle", "gym", "squash", "sport", "yoga", "hike", "other"] + private static let activityEnum = ["walk", "run", "cycle", "gym", "squash", "sport", "yoga", "dance", "hike", "other"] // MARK: set_goal @@ -157,6 +157,7 @@ enum ActionTools { ctx.modelContext.insert(session) _ = ActivityService.finishSummary(for: session, endedAt: end, context: ctx.modelContext) try? ctx.modelContext.save() + HealthSyncService.shared.triggerAutomaticSync(context: ctx.modelContext, delaySeconds: 1.0) return .object(["ok": true, "created": true, "activity_id": session.id.uuidString, "type": args.activityType, "duration_min": duration]) } @@ -202,6 +203,7 @@ enum ActionTools { let isToday = Calendar.current.isDateInToday(session.startedAt) if isToday { applyUpdatesNow(updates, to: session, context: ctx.modelContext) + HealthSyncService.shared.triggerAutomaticSync(context: ctx.modelContext, delaySeconds: 1.0) return .object(["ok": true, "updated": true, "activity_id": args.activityId]) } // Older session → confirm. diff --git a/PulseLoop/DesignSystem/Components.swift b/PulseLoop/DesignSystem/Components.swift index 8f0aa6f..1034608 100644 --- a/PulseLoop/DesignSystem/Components.swift +++ b/PulseLoop/DesignSystem/Components.swift @@ -602,6 +602,7 @@ enum ActivityMeta { struct ActivityWorkoutRow: View { let session: ActivitySession + var units: UnitsPreference = .metric var onTap: (() -> Void)? var body: some View { @@ -626,7 +627,8 @@ struct ActivityWorkoutRow: View { HStack(spacing: 12) { Text(durationLabel).font(.system(size: 12).monospacedDigit()) if let distance = session.distanceMeters { - Text(String(format: "%.2f km", distance / 1000)).font(.system(size: 12).monospacedDigit()) + let d = UnitsFormatter.distance(meters: distance, units: units) + Text("\(d.value) \(d.unit)").font(.system(size: 12).monospacedDigit()) } if let hr = session.avgHeartRate { Text("\(Int(hr)) bpm avg").font(.system(size: 12).monospacedDigit()) diff --git a/PulseLoop/Events/PulseEventBus.swift b/PulseLoop/Events/PulseEventBus.swift index 0c656f9..21846f8 100644 --- a/PulseLoop/Events/PulseEventBus.swift +++ b/PulseLoop/Events/PulseEventBus.swift @@ -144,7 +144,7 @@ final class EventPersistenceSubscriber { calories: calories, distanceMeters: distanceMeters, source: "live", - syncedAt: Date() + syncedAt: nil ), context: context ) @@ -154,28 +154,36 @@ final class EventPersistenceSubscriber { entityId: row.id.uuidString, payloadJSON: #"{"steps":\#(row.steps),"calories":\#(Int(row.calories)),"distance_m":\#(Int(row.distanceMeters))}"# )) + HealthSyncService.shared.triggerAutomaticSync(context: context) case let .activityBucket(timestamp, steps, distanceMeters): // Per-quarter-hour ring history: upserted by timestamp + the day total recomputed as the // sum of distinct buckets, so re-syncs are idempotent (no drift). Calories omitted. ActivityService.applyActivityBucket(date: timestamp, steps: steps, distanceMeters: distanceMeters, context: context) + HealthSyncService.shared.triggerAutomaticSync(context: context) case .activitySyncReset: // No longer needed — bucket upsert-by-timestamp makes re-syncs idempotent on its own. // Kept as a no-op so the (still-published) event doesn't fall through to `unknown`. break case let .heartRateSample(bpm, timestamp): persistMeasurement(kind: .heartRate, value: Double(bpm), timestamp: timestamp, source: .live, kindLabel: "hr_sample") + HealthSyncService.shared.triggerAutomaticSync(context: context) case let .spo2Result(value, timestamp): persistMeasurement(kind: .spo2, value: Double(value), timestamp: timestamp, source: .live, kindLabel: "spo2_result") + HealthSyncService.shared.triggerAutomaticSync(context: context) case let .historyMeasurement(kind, value, timestamp): persistMeasurement(kind: kind, value: value, timestamp: timestamp, source: .history, kindLabel: "history_measurement") + HealthSyncService.shared.triggerAutomaticSync(context: context) case let .stressSample(value, timestamp): persistMeasurement(kind: .stress, value: Double(value), timestamp: timestamp, source: .colmi, kindLabel: "stress_sample") case let .hrvSample(value, timestamp): persistMeasurement(kind: .hrv, value: Double(value), timestamp: timestamp, source: .colmi, kindLabel: "hrv_sample") + HealthSyncService.shared.triggerAutomaticSync(context: context) case let .temperatureSample(celsius, timestamp): persistMeasurement(kind: .temperature, value: celsius, timestamp: timestamp, source: .colmi, kindLabel: "temperature_sample") + HealthSyncService.shared.triggerAutomaticSync(context: context) case let .sleepTimeline(timestamp, stages): persistSleepTimeline(start: timestamp, stages: stages) + HealthSyncService.shared.triggerAutomaticSync(context: context) case let .gpsPoint(sessionId, latitude, longitude, altitude, horizontalAccuracy, speed, course, accepted, rejectionReason, timestamp): context.insert(ActivityGpsPoint( sessionId: sessionId, @@ -203,11 +211,26 @@ final class EventPersistenceSubscriber { try? context.save() } - /// Persist one live/history measurement, record a derived-update audit row, and link it to - /// an in-progress workout if one is recording. Mirrors `persistence._on_hr_sample`. private func persistMeasurement(kind: MeasurementKind, value: Double, timestamp: Date, source: MeasurementSource, kindLabel: String) { - let row = Measurement(kind: kind, value: value, unit: kind.unit, timestamp: timestamp, source: source) - context.insert(row) + let kindRaw = kind.rawValue + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.kindRaw == kindRaw && $0.timestamp == timestamp } + ) + let existing = (try? context.fetch(descriptor))?.first + + let row: Measurement + if let existing { + row = existing + if existing.value != value || existing.sourceRaw != source.rawValue { + existing.value = value + existing.sourceRaw = source.rawValue + existing.syncedAt = nil // Reset sync status so it pushes the updated value to HealthKit + } + } else { + row = Measurement(kind: kind, value: value, unit: kind.unit, timestamp: timestamp, source: source) + context.insert(row) + } + context.insert(DerivedUpdateRow(kind: kindLabel, entityType: "measurement", entityId: row.id.uuidString)) _ = ActivityRecorderService.linkSample( kind: kind, @@ -220,6 +243,7 @@ final class EventPersistenceSubscriber { ) } + /// Upsert a sleep session by night, appending this packet's per-minute stage blocks and /// recomputing session bounds. The ring streams ~20 timeline packets (15 samples each) per /// night, so blocks must accumulate into one session rather than spawning a session per diff --git a/PulseLoop/Health/HealthSettingsDetailView.swift b/PulseLoop/Health/HealthSettingsDetailView.swift new file mode 100644 index 0000000..59a91bc --- /dev/null +++ b/PulseLoop/Health/HealthSettingsDetailView.swift @@ -0,0 +1,16 @@ +import SwiftUI + +/// Apple Health detail screen. `HealthSettingsSection` emits bare section content (no scroll +/// container of its own), so it drops straight in here — matching the `CoachSettingsDetailView` idiom. +struct HealthSettingsDetailView: View { + var body: some View { + ScrollView { + VStack(spacing: 16) { + HealthSettingsSection() + } + .padding() + } + .background(PulseColors.background) + .navigationTitle("Apple Health") + } +} diff --git a/PulseLoop/Health/HealthSettingsSection.swift b/PulseLoop/Health/HealthSettingsSection.swift new file mode 100644 index 0000000..a133cb0 --- /dev/null +++ b/PulseLoop/Health/HealthSettingsSection.swift @@ -0,0 +1,131 @@ +import SwiftUI +import UIKit + +/// "Apple Health" block for `SettingsView`, shown above the AI Coach section. +/// Tapping **Apple Health** checks the connection: if already authorized it confirms +/// so, otherwise it presents the system Health Access sheet (read + write for every +/// metric the ring captures). **Sync workouts history** exports everything captured. +struct HealthSettingsSection: View { + @Environment(\.modelContext) private var modelContext + @State private var service = HealthSyncService.shared + @State private var alert: HealthAlert? + @State private var statusText = "—" + + var body: some View { + Group { + SectionHeader(title: "Apple Health", action: nil) + StatusCopy(title: "Status", body: statusText) + + SecondaryButton(title: "Apple Health", systemImage: "heart.fill") { + handleAppleHealthTap() + } + + SecondaryButton( + title: service.isSyncing ? "Syncing…" : "Sync workouts history", + systemImage: "arrow.triangle.2.circlepath" + ) { + syncHistory() + } + .disabled(service.isSyncing) + + if let result = service.lastResult { + Text(result) + .font(.caption) + .foregroundStyle(PulseColors.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .onAppear(perform: refreshStatus) + .alert( + alert?.title ?? "", + isPresented: Binding(get: { alert != nil }, set: { if !$0 { alert = nil } }), + presenting: alert + ) { a in + if a.showSettings { + Button("Open Settings") { openSettings() } + } + Button("OK", role: .cancel) {} + } message: { a in + Text(a.message) + } + } + + // MARK: - Actions + + private func handleAppleHealthTap() { + switch service.authState { + case .unavailable: + alert = HealthAlert(title: "Apple Health Unavailable", + message: "Apple Health isn't available on this device.") + case .authorized: + alert = HealthAlert(title: "Apple Health Connected", + message: "PulseLoop is connected to Apple Health and can read and write your ring's data. Use “Sync workouts history” to push everything captured.") + case .denied: + alert = HealthAlert(title: "Apple Health Access Off", + message: "Allow PulseLoop to read and write health data in the Health app (under Sharing → Apps & Services) or in system Settings → Health → Data Access & Devices.", + showSettings: true) + case .notDetermined: + Task { + do { + try await service.requestAuthorization() + refreshStatus() + switch service.authState { + case .authorized: + alert = HealthAlert(title: "Apple Health Connected", + message: "PulseLoop can now sync your ring data to Apple Health. Tap “Sync workouts history” to export everything captured.") + default: + alert = HealthAlert( + title: "Apple Health", + message: "You can change PulseLoop's access anytime in the Health app " + + "(under Sharing → Apps & Services) or in system Settings → " + + "Health → Data Access & Devices.", + showSettings: true) + } + } catch { + alert = HealthAlert(title: "Apple Health", message: error.localizedDescription) + } + } + } + } + + private func syncHistory() { + Task { + await service.syncAll(context: modelContext, forceAll: true) + refreshStatus() + if service.authState == .denied { + alert = HealthAlert( + title: "Apple Health Access Off", + message: "Allow PulseLoop to read and write health data in the Health app " + + "(under Sharing → Apps & Services) or in system Settings → " + + "Health → Data Access & Devices.", + showSettings: true) + } + } + } + + private func refreshStatus() { + switch service.authState { + case .unavailable: statusText = "Not available on this device" + case .authorized: statusText = "Connected — reading & writing ring data" + case .denied: statusText = "Access off (enable in Settings)" + case .notDetermined: statusText = "Not connected" + } + } + + private func openSettings() { + if let healthURL = URL(string: "x-apple-health://") { + UIApplication.shared.open(healthURL) { success in + if !success, let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + } + } +} + +private struct HealthAlert: Identifiable { + let id = UUID() + let title: String + let message: String + var showSettings = false +} diff --git a/PulseLoop/Health/HealthSyncService.swift b/PulseLoop/Health/HealthSyncService.swift new file mode 100644 index 0000000..3ee926e --- /dev/null +++ b/PulseLoop/Health/HealthSyncService.swift @@ -0,0 +1,850 @@ +import Foundation +import HealthKit +import CoreLocation +import SwiftData +import os + +/// Mirrors everything the ring captures into Apple Health and requests read/write +/// access for those types: +/// • Vitals — heart rate, blood oxygen (SpO₂), HRV, skin temperature +/// • Daily activity — steps, active energy, walking/running distance +/// • Workouts — type, energy, distance, and the recorded GPS route +/// • Sleep — per-stage segments (deep / core / REM / awake) +/// +/// The export is idempotent: every sync first deletes the objects this app wrote +/// previously, then writes the current state, so pressing "Sync" repeatedly never +/// duplicates data. Stress has no native HealthKit equivalent and is skipped. +/// +/// It also runs one *import* in the other direction: `importWorkouts` reads workouts +/// logged by other apps (Apple Watch, Whoop, Strava, …) and mirrors them into local +/// `ActivitySession` rows so they appear in PulseLoop's activity section. Imported rows +/// are tagged with their source `HKWorkout.uuid` and are never written back to Health. +@MainActor +@Observable +final class HealthSyncService { + static let shared = HealthSyncService() + + private let store = HKHealthStore() + private let log = Logger(subsystem: "com.pulseloop", category: "HealthSync") + private var pendingSyncTask: Task? + + /// True while a sync is running (drives the Settings button state). + var isSyncing = false + /// Human-readable result of the most recent sync, shown under the button. + var lastResult: String? + + var isAvailable: Bool { HKHealthStore.isHealthDataAvailable() } + + private init() {} + + // MARK: - Authorization state + + enum AuthState { case unavailable, notDetermined, denied, authorized } + + /// HealthKit never reveals *read* authorization (for privacy), so "connected" + /// is judged purely on whether we can *share* (write) our types. + var authState: AuthState { + guard isAvailable else { return .unavailable } + let statuses = shareTypes.map { store.authorizationStatus(for: $0) } + if statuses.contains(.sharingAuthorized) { return .authorized } + if statuses.allSatisfy({ $0 == .notDetermined }) { return .notDetermined } + return .denied + } + + /// Presents the system "Health Access" sheet (a no-op if already answered). + func requestAuthorization() async throws { + guard isAvailable else { throw HealthSyncError.unavailable } + try await store.requestAuthorization(toShare: shareTypes, read: readTypes) + } + + // MARK: - Type catalogue + + private var quantityWriteTypes: [HKQuantityType] { + [ + .heartRate, .oxygenSaturation, .heartRateVariabilitySDNN, .bodyTemperature, + .stepCount, .activeEnergyBurned, .distanceWalkingRunning, .distanceCycling + ].compactMap { HKQuantityType.quantityType(forIdentifier: $0) } + } + + private var shareTypes: Set { + var set = Set(quantityWriteTypes) + if let sleep = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) { set.insert(sleep) } + set.insert(HKObjectType.workoutType()) + set.insert(HKSeriesType.workoutRoute()) + return set + } + + /// Read access mirrors write access — the app asks for everything it captures. + private var readTypes: Set { + var set = Set() + shareTypes.forEach { set.insert($0) } + if let dob = HKObjectType.characteristicType(forIdentifier: .dateOfBirth) { set.insert(dob) } + if let sex = HKObjectType.characteristicType(forIdentifier: .biologicalSex) { set.insert(sex) } + if let height = HKQuantityType.quantityType(forIdentifier: .height) { set.insert(height) } + if let weight = HKQuantityType.quantityType(forIdentifier: .bodyMass) { set.insert(weight) } + return set + } + + struct HealthProfileData { + let age: Int? + let sex: String? + let heightCm: Double? + let weightKg: Double? + } + + func fetchUserProfileData() async -> HealthProfileData { + var age: Int? = nil + if let dob = try? store.dateOfBirthComponents() { + if let birthDate = Calendar.current.date(from: dob) { + let ageComponents = Calendar.current.dateComponents([.year], from: birthDate, to: Date()) + age = ageComponents.year + } + } + + var sexString: String? = nil + if let biologicalSexWrapper = try? store.biologicalSex() { + switch biologicalSexWrapper.biologicalSex { + case .female: sexString = "female" + case .male: sexString = "male" + case .other: sexString = "other" + default: sexString = nil + } + } + + let heightType = HKQuantityType.quantityType(forIdentifier: .height) + let heightCm: Double? = await withCheckedContinuation { continuation in + guard let heightType else { + continuation.resume(returning: nil) + return + } + let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false) + let query = HKSampleQuery(sampleType: heightType, predicate: nil, limit: 1, sortDescriptors: [sortDescriptor]) { _, samples, _ in + let val = (samples?.first as? HKQuantitySample)?.quantity.doubleValue(for: HKUnit.meterUnit(with: .centi)) + continuation.resume(returning: val) + } + store.execute(query) + } + + let weightType = HKQuantityType.quantityType(forIdentifier: .bodyMass) + let weightKg: Double? = await withCheckedContinuation { continuation in + guard let weightType else { + continuation.resume(returning: nil) + return + } + let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false) + let query = HKSampleQuery(sampleType: weightType, predicate: nil, limit: 1, sortDescriptors: [sortDescriptor]) { _, samples, _ in + let val = (samples?.first as? HKQuantitySample)?.quantity.doubleValue(for: HKUnit.gramUnit(with: .kilo)) + continuation.resume(returning: val) + } + store.execute(query) + } + + return HealthProfileData(age: age, sex: sexString, heightCm: heightCm, weightKg: weightKg) + } + + // MARK: - Sync + + /// Exports all captured data to Apple Health. Returns a short status string and + /// also publishes it via `lastResult`. + @discardableResult + func syncAll(context: ModelContext, forceAll: Bool = false) async -> String { + guard isAvailable else { + let msg = "Apple Health isn't available on this device." + lastResult = msg + return msg + } + isSyncing = true + defer { isSyncing = false } + + if forceAll { + resetSyncStatus(context: context) + } + + // Make sure we have permission (no-op once the user has answered the prompt). + do { + try await requestAuthorization() + } catch { + let msg = "Couldn't get Apple Health permission." + lastResult = msg + return msg + } + guard authState == .authorized else { + let msg = "Apple Health access is off — enable it in Settings." + lastResult = msg + return msg + } + + // Workouts are needed both to build HKWorkouts and to net them out of the + // daily totals (so active energy isn't counted twice in the Move ring). Only + // *natively recorded* sessions count here — workouts imported from Health were + // logged by another app and aren't part of the ring's daily totals, so they + // must neither be written back nor subtracted from those totals. + let finishedSessions = ActivityRepository.sessions(context: context) + .filter { $0.status == .finished && $0.endedAt != nil && $0.healthKitWorkoutID == nil } + + // Phase 1 — incremental sync of unsynced state. + var counts = SyncCounts() + do { + await syncUserProfile(context: context) + try await syncMeasurements(context: context, counts: &counts) + try await syncDailyActivity(sessions: finishedSessions, context: context, counts: &counts) + try await syncSleep(context: context, counts: &counts) + + let unsyncedSessions = ActivityRepository.unsyncedSessions(context: context) + .filter { $0.status == .finished && $0.endedAt != nil && $0.healthKitWorkoutID == nil } + try await syncWorkouts(sessions: unsyncedSessions, context: context, counts: &counts) + + // Phase 2 — import workouts logged by *other* apps (Whoop, Strava, Apple + // Watch, …) so they appear in PulseLoop's activity section too. + try await importWorkouts(context: context, counts: &counts) + } catch { + log.error("Health sync failed: \(error.localizedDescription)") + let msg = "Synced with errors: \(error.localizedDescription)" + lastResult = msg + return msg + } + + let msg = counts.summary + lastResult = msg + return msg + } + + /// Schedules a debounced sync of all data to Apple Health. + /// If another sync is requested before the debounce delay expires, the previous request is cancelled. + func triggerAutomaticSync(context: ModelContext, delaySeconds: TimeInterval = 15) { + #if DEBUG + if NSClassFromString("XCTestCase") != nil || ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil { + return + } + #endif + + guard authState == .authorized else { return } + + pendingSyncTask?.cancel() + pendingSyncTask = Task { + do { + try await Task.sleep(nanoseconds: UInt64(delaySeconds * 1_000_000_000)) + _ = await syncAll(context: context) + } catch { + // Cancelled + } + } + } + + private func syncUserProfile(context: ModelContext) async { + let data = await fetchUserProfileData() + let descriptor = FetchDescriptor() + guard let profile = (try? context.fetch(descriptor))?.first else { return } + + var modified = false + if let age = data.age, profile.age != age { + profile.age = age + modified = true + } + if let sex = data.sex, profile.sex != sex { + profile.sex = sex + modified = true + } + if let height = data.heightCm, profile.heightCm != height { + profile.heightCm = height + modified = true + } + if let weight = data.weightKg, profile.weightKg != weight { + profile.weightKg = weight + modified = true + } + + if modified { + profile.updatedAt = Date() + try? context.save() + } + } + + // MARK: - Vitals (heart rate, SpO₂, HRV, temperature) + + private func syncMeasurements(context: ModelContext, counts: inout SyncCounts) async throws { + // 1. Deduplicate local SwiftData measurements first to resolve any existing duplicate rows. + let allMeasurements = MetricsRepository.measurements(context: context) + var grouped: [String: [Measurement]] = [:] + for m in allMeasurements { + let key = "\(m.kindRaw)-\(m.timestamp.timeIntervalSince1970)" + grouped[key, default: []].append(m) + } + + var uuidsToDeleteFromHealth: [String] = [] + var localDeletedCount = 0 + + for (_, list) in grouped where list.count > 1 { + // Keep one: prefer one that is already synced, or just the first one + let sorted = list.sorted { (m1, m2) -> Bool in + if m1.syncedAt != nil && m2.syncedAt == nil { return true } + if m1.syncedAt == nil && m2.syncedAt != nil { return false } + return m1.id.uuidString < m2.id.uuidString + } + let toKeep = sorted[0] + let toDelete = sorted.suffix(from: 1) + + for m in toDelete { + uuidsToDeleteFromHealth.append(m.id.uuidString) + context.delete(m) + localDeletedCount += 1 + } + } + + if localDeletedCount > 0 { + try? context.save() + log.info("Deduplicated \(localDeletedCount) duplicate measurements locally.") + } + + if !uuidsToDeleteFromHealth.isEmpty { + await deleteByExternalUUIDs(uuidsToDeleteFromHealth) + log.info("Requested deletion of \(uuidsToDeleteFromHealth.count) duplicate measurements from Apple Health.") + } + + // Skip seeded/demo rows so we never push synthetic data into Health. + let measurements = MetricsRepository.unsyncedMeasurements(context: context) + .filter { $0.sourceRaw != MeasurementSource.mock.rawValue } + guard !measurements.isEmpty else { return } + + // Delete from Health first before writing to avoid any chance of duplication + let externalUUIDs = measurements.map { $0.id.uuidString } + await deleteByExternalUUIDs(externalUUIDs) + + var byType: [HKQuantityType: [HKQuantitySample]] = [:] + for m in measurements { + guard let mapping = Self.quantityMapping(for: m.kind), canShare(mapping.type) else { continue } + let value = mapping.convert(m.value) + guard value.isFinite, mapping.isPlausible(value) else { continue } + let sample = HKQuantitySample( + type: mapping.type, + quantity: HKQuantity(unit: mapping.unit, doubleValue: value), + start: m.timestamp, + end: m.timestamp, + metadata: [HKMetadataKeyExternalUUID: m.id.uuidString] + ) + byType[mapping.type, default: []].append(sample) + } + + for samples in byType.values { + try await save(samples) + counts.measurements += samples.count + } + + for m in measurements { + if let mapping = Self.quantityMapping(for: m.kind) { + if canShare(mapping.type) { + m.syncedAt = Date() + } + } else { + m.syncedAt = Date() + } + } + try? context.save() + } + + + // MARK: - Daily activity (steps, active energy, distance) + + private func syncDailyActivity(sessions: [ActivitySession], context: ModelContext, counts: inout SyncCounts) async throws { + let rows = MetricsRepository.unsyncedActivityRows(context: context).filter { $0.source != "mock" } + guard !rows.isEmpty else { return } + let cal = Calendar.current + + // Per-day workout totals so the daily aggregate only carries the *non-workout* + // remainder; workouts add their own samples below. Sum = the captured day total. + var workoutKcalByDay: [Date: Double] = [:] + var workoutMetersByDay: [Date: Double] = [:] + for s in sessions { + let day = cal.startOfDay(for: s.startedAt) + if let k = s.calories, k > 0 { workoutKcalByDay[day, default: 0] += k } + if let m = s.distanceMeters, m > 0 { workoutMetersByDay[day, default: 0] += m } + } + + let stepType = HKQuantityType.quantityType(forIdentifier: .stepCount) + let energyType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned) + let distType = HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning) + + var steps: [HKQuantitySample] = [] + var energy: [HKQuantitySample] = [] + var distance: [HKQuantitySample] = [] + + for row in rows { + let dayStart = cal.startOfDay(for: row.date) + guard let nextDay = cal.date(byAdding: .day, value: 1, to: dayStart) else { continue } + // Clamp "today" to now so we never write a sample ending in the future. + let dayEnd = min(nextDay.addingTimeInterval(-1), Date()) + guard dayEnd > dayStart else { continue } + + if let stepType, canShare(stepType), row.steps > 0 { + steps.append(HKQuantitySample( + type: stepType, + quantity: HKQuantity(unit: .count(), doubleValue: Double(row.steps)), + start: dayStart, end: dayEnd, + metadata: [HKMetadataKeyExternalUUID: "steps-\(row.id.uuidString)"])) + } + if let energyType, canShare(energyType) { + let leftover = row.calories - (workoutKcalByDay[dayStart] ?? 0) + if leftover > 0 { + energy.append(HKQuantitySample( + type: energyType, + quantity: HKQuantity(unit: .kilocalorie(), doubleValue: leftover), + start: dayStart, end: dayEnd, + metadata: [HKMetadataKeyExternalUUID: "energy-\(row.id.uuidString)"])) + } + } + if let distType, canShare(distType) { + let leftover = row.distanceMeters - (workoutMetersByDay[dayStart] ?? 0) + if leftover > 0 { + distance.append(HKQuantitySample( + type: distType, + quantity: HKQuantity(unit: .meter(), doubleValue: leftover), + start: dayStart, end: dayEnd, + metadata: [HKMetadataKeyExternalUUID: "distance-\(row.id.uuidString)"])) + } + } + } + + let externalUUIDs = rows.flatMap { ["steps-\($0.id.uuidString)", "energy-\($0.id.uuidString)", "distance-\($0.id.uuidString)"] } + await deleteByExternalUUIDs(externalUUIDs) + + for samples in [steps, energy, distance] where !samples.isEmpty { + try await save(samples) + counts.dailyMetrics += samples.count + } + + let stepAuthorized = stepType.map { canShare($0) } ?? true + let energyAuthorized = energyType.map { canShare($0) } ?? true + let distAuthorized = distType.map { canShare($0) } ?? true + + if stepAuthorized && energyAuthorized && distAuthorized { + for row in rows { + row.syncedAt = Date() + } + try? context.save() + } + } + + // MARK: - Sleep + + private func syncSleep(context: ModelContext, counts: inout SyncCounts) async throws { + guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis), canShare(sleepType) else { return } + let sessions = SleepRepository.unsyncedSessions(context: context) + guard !sessions.isEmpty else { return } + + var samples: [HKCategorySample] = [] + for session in sessions { + let blocks = SleepRepository.blocks(sessionId: session.id, context: context) + if blocks.isEmpty { + guard session.endAt > session.startAt else { continue } + samples.append(HKCategorySample( + type: sleepType, + value: HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue, + start: session.startAt, end: session.endAt, + metadata: [HKMetadataKeyExternalUUID: "sleep-\(session.id.uuidString)"])) + continue + } + for block in blocks { + let end = block.startAt.addingTimeInterval(Double(block.durationMinutes) * 60) + guard end > block.startAt else { continue } + samples.append(HKCategorySample( + type: sleepType, + value: Self.sleepValue(block.stage).rawValue, + start: block.startAt, end: end, + metadata: [HKMetadataKeyExternalUUID: "sleepblock-\(block.id.uuidString)"])) + } + } + let externalUUIDs = sessions.map { "sleep-\($0.id.uuidString)" } + sessions.flatMap { session in + SleepRepository.blocks(sessionId: session.id, context: context).map { "sleepblock-\($0.id.uuidString)" } + } + await deleteByExternalUUIDs(externalUUIDs) + + guard !samples.isEmpty else { return } + try await save(samples) + counts.sleep = samples.count + + for session in sessions { + session.syncedAt = Date() + } + try? context.save() + } + + // MARK: - Workouts (+ HR association by time + GPS route) + + private func syncWorkouts(sessions: [ActivitySession], context: ModelContext, counts: inout SyncCounts) async throws { + guard canShare(HKObjectType.workoutType()), !sessions.isEmpty else { return } + + let uuids = sessions.map { $0.id.uuidString } + await deleteByExternalUUIDs(uuids) + + var saved = 0 + for session in sessions { + guard let end = session.endedAt, end > session.startedAt else { continue } + do { + try await buildWorkout(session: session, end: end, context: context) + session.syncedAt = Date() + saved += 1 + } catch { + log.error("Workout \(session.id.uuidString) sync failed: \(error.localizedDescription)") + } + } + counts.workouts = saved + try? context.save() + } + + private func buildWorkout(session: ActivitySession, end: Date, context: ModelContext) async throws { + let config = HKWorkoutConfiguration() + config.activityType = Self.workoutActivityType(for: session.type) + config.locationType = session.useGps ? .outdoor : .indoor + + let builder = HKWorkoutBuilder(healthStore: store, configuration: config, device: .local()) + try await builder.beginCollection(at: session.startedAt) + + var samples: [HKSample] = [] + if let kcal = session.calories, kcal > 0, + let energyType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned), canShare(energyType) { + samples.append(HKQuantitySample( + type: energyType, + quantity: HKQuantity(unit: .kilocalorie(), doubleValue: kcal), + start: session.startedAt, end: end)) + } + if let meters = session.distanceMeters, meters > 0, + let distType = Self.distanceType(for: session.type), canShare(distType) { + samples.append(HKQuantitySample( + type: distType, + quantity: HKQuantity(unit: .meter(), doubleValue: meters), + start: session.startedAt, end: end)) + } + if !samples.isEmpty { + try await builder.addSamples(samples) + } + try await builder.addMetadata([HKMetadataKeyExternalUUID: session.id.uuidString]) + try await builder.endCollection(at: end) + guard let workout = try await builder.finishWorkout() else { return } + + // Attach the recorded GPS route, if any accepted fixes exist. + let points = ActivityRepository.gpsPoints(sessionId: session.id, context: context) + .filter { $0.accepted } + .sorted { $0.timestamp < $1.timestamp } + guard points.count >= 2, canShare(HKSeriesType.workoutRoute()) else { return } + let locations = points.map { p in + CLLocation( + coordinate: CLLocationCoordinate2D(latitude: p.latitude, longitude: p.longitude), + altitude: p.altitude ?? 0, + horizontalAccuracy: p.horizontalAccuracy ?? 5, + verticalAccuracy: p.altitude != nil ? 5 : -1, + course: p.course ?? -1, + speed: p.speed ?? -1, + timestamp: p.timestamp) + } + let routeBuilder = HKWorkoutRouteBuilder(healthStore: store, device: .local()) + try await routeBuilder.insertRouteData(locations) + try await routeBuilder.finishRoute(with: workout, metadata: nil) + } + + // MARK: - HealthKit plumbing + + private func canShare(_ type: HKSampleType) -> Bool { + store.authorizationStatus(for: type) == .sharingAuthorized + } + + private func save(_ objects: [HKObject]) async throws { + guard !objects.isEmpty else { return } + do { + try await store.save(objects) + } catch { + log.error("Batch save failed, falling back to per-object. Error: \(error.localizedDescription)") + // A single duplicate (same external UUID) fails the whole batch; fall + // back to per-object saves so the rest still land. + for object in objects { + do { + try await store.save([object]) + } catch { + log.error("Failed to save object \(object): \(error.localizedDescription)") + } + } + } + } + + private func deleteByExternalUUIDs(_ uuids: [String]) async { + guard !uuids.isEmpty else { return } + let metaPredicate = HKQuery.predicateForObjects(withMetadataKey: HKMetadataKeyExternalUUID, allowedValues: uuids) + let sourcePredicate = HKQuery.predicateForObjects(from: HKSource.default()) + let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [sourcePredicate, metaPredicate]) + let allTypes = shareTypes.filter { $0 is HKQuantityType || $0 is HKCategoryType || $0 is HKWorkoutType } + + await withTaskGroup(of: Void.self) { group in + for type in allTypes where canShare(type) { + group.addTask { + await withCheckedContinuation { (cont: CheckedContinuation) in + self.store.deleteObjects(of: type, predicate: predicate) { success, count, error in + if let error { + self.log.error("deleteObjects failed for \(type.identifier): \(error.localizedDescription)") + } + cont.resume() + } + } + } + } + } + } + + // MARK: - Mappings + + private struct QuantityMapping { + let type: HKQuantityType + let unit: HKUnit + let convert: (Double) -> Double + let isPlausible: (Double) -> Bool + } + + private static func quantityMapping(for kind: MeasurementKind) -> QuantityMapping? { + switch kind { + case .heartRate: + guard let t = HKQuantityType.quantityType(forIdentifier: .heartRate) else { return nil } + return QuantityMapping(type: t, unit: HKUnit.count().unitDivided(by: .minute()), + convert: { $0 }, isPlausible: { $0 >= 20 && $0 <= 300 }) + case .spo2: + guard let t = HKQuantityType.quantityType(forIdentifier: .oxygenSaturation) else { return nil } + // Ring stores SpO₂ as a percent (e.g. 96); HealthKit wants a 0…1 fraction. + return QuantityMapping(type: t, unit: .percent(), + convert: { $0 > 1 ? $0 / 100 : $0 }, isPlausible: { $0 >= 0.5 && $0 <= 1.0 }) + case .hrv: + guard let t = HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN) else { return nil } + return QuantityMapping(type: t, unit: HKUnit.secondUnit(with: .milli), + convert: { $0 }, isPlausible: { $0 > 0 && $0 < 1000 }) + case .temperature: + guard let t = HKQuantityType.quantityType(forIdentifier: .bodyTemperature) else { return nil } + return QuantityMapping(type: t, unit: .degreeCelsius(), + convert: { $0 }, isPlausible: { $0 > 25 && $0 < 45 }) + case .stress: + return nil // No native HealthKit equivalent. + } + } + + private static func workoutActivityType(for type: String) -> HKWorkoutActivityType { + switch ActivityMeta.meta(type).type { + case "walk": return .walking + case "run": return .running + case "cycle": return .cycling + case "gym": return .traditionalStrengthTraining + case "squash": return .squash + case "sport": return .mixedCardio + case "yoga": return .yoga + case "dance": return .cardioDance + case "hike": return .hiking + default: return .other + } + } + + private static func distanceType(for type: String) -> HKQuantityType? { + switch ActivityMeta.meta(type).type { + case "cycle": return HKQuantityType.quantityType(forIdentifier: .distanceCycling) + default: return HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning) + } + } + + private static func sleepValue(_ stage: SleepStage) -> HKCategoryValueSleepAnalysis { + switch stage { + case .deep: return .asleepDeep + case .light: return .asleepCore + case .rem: return .asleepREM + case .awake: return .awake + case .unknown: return .asleepUnspecified + } + } + + // MARK: - Result accounting + + private struct SyncCounts { + var measurements = 0 + var dailyMetrics = 0 + var sleep = 0 + var workouts = 0 + var workoutsImported = 0 + + var summary: String { + var parts: [String] = [] + if workouts > 0 { parts.append("\(workouts) workout\(workouts == 1 ? "" : "s")") } + if measurements > 0 { parts.append("\(measurements) vitals") } + if sleep > 0 { parts.append("\(sleep) sleep segment\(sleep == 1 ? "" : "s")") } + if dailyMetrics > 0 { parts.append("\(dailyMetrics) daily total\(dailyMetrics == 1 ? "" : "s")") } + var sentence = parts.isEmpty ? "" : "Synced " + parts.joined(separator: ", ") + " to Apple Health." + if workoutsImported > 0 { + let imported = "Imported \(workoutsImported) workout\(workoutsImported == 1 ? "" : "s") from Apple Health." + sentence = sentence.isEmpty ? imported : sentence + " " + imported + } + return sentence.isEmpty ? "Nothing to sync yet." : sentence + } + } + + private func resetSyncStatus(context: ModelContext) { + log.info("Resetting Apple Health sync status for all local records to force full re-sync.") + let measurements = (try? context.fetch(FetchDescriptor())) ?? [] + for m in measurements { + m.syncedAt = nil + } + let activities = (try? context.fetch(FetchDescriptor())) ?? [] + for a in activities { + a.syncedAt = nil + } + let sleep = (try? context.fetch(FetchDescriptor())) ?? [] + for s in sleep { + s.syncedAt = nil + } + let workouts = (try? context.fetch(FetchDescriptor())) ?? [] + for w in workouts where w.healthKitWorkoutID == nil { + // Never re-export workouts that were imported *from* Health — only natively + // recorded sessions should be eligible for a forced re-sync. + w.syncedAt = nil + } + try? context.save() + } +} + +// MARK: - Workout import (read workouts logged by other apps) + +extension HealthSyncService { + + /// How far back the first import scans Apple Health. Later syncs dedupe by UUID, so this + /// only bounds the *initial* backfill rather than re-scanning the user's full history. + private static let importLookbackDays = 365 + + /// Pulls `HKWorkout`s recorded by *other* apps (Apple Watch, Whoop, Strava, …) into local + /// `ActivitySession` rows so they appear in PulseLoop's activity section alongside natively + /// recorded workouts. Workouts this app itself wrote to Health are skipped (their source is + /// us — importing them would loop), and each external workout is keyed by its `HKWorkout.uuid` + /// so repeat syncs dedupe instead of duplicating. Imported rows are marked `syncedAt` and carry + /// a `healthKitWorkoutID`, so the export path never writes them back. + private func importWorkouts(context: ModelContext, counts: inout SyncCounts) async throws { + let workoutType = HKObjectType.workoutType() + // Read auth is private in HealthKit; if the user never saw the prompt there's nothing to do. + guard store.authorizationStatus(for: workoutType) != .notDetermined else { return } + + let lookbackStart = Calendar.current.date(byAdding: .day, value: -Self.importLookbackDays, to: Date()) + let predicate = HKQuery.predicateForSamples(withStart: lookbackStart, end: nil, options: .strictStartDate) + + let workouts: [HKWorkout] = try await withCheckedThrowingContinuation { continuation in + let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false) + let query = HKSampleQuery( + sampleType: workoutType, predicate: predicate, + limit: HKObjectQueryNoLimit, sortDescriptors: [sort] + ) { _, samples, error in + if let error { continuation.resume(throwing: error); return } + continuation.resume(returning: (samples as? [HKWorkout]) ?? []) + } + store.execute(query) + } + guard !workouts.isEmpty else { return } + + // Skip workouts this app wrote to Health — we already have those locally. + let ownBundleID = Bundle.main.bundleIdentifier + let external = workouts.filter { $0.sourceRevision.source.bundleIdentifier != ownBundleID } + guard !external.isEmpty else { return } + + // Dedupe against previously imported workouts by their source UUID. + let alreadyImported = Set( + ActivityRepository.sessions(context: context).compactMap { $0.healthKitWorkoutID } + ) + + var imported = 0 + for workout in external where !alreadyImported.contains(workout.uuid) { + guard workout.endDate > workout.startDate else { continue } + let session = ActivitySession( + type: Self.activityType(from: workout.workoutActivityType), + status: .finished, + startedAt: workout.startDate, + endedAt: workout.endDate, + calories: Self.energyKcal(from: workout), + distanceMeters: Self.distanceMeters(from: workout), + useGps: false, + // Already "synced" from our perspective — it originated in Health — so the + // export path leaves it alone. The healthKitWorkoutID is the durable guard. + syncedAt: Date() + ) + session.healthKitWorkoutID = workout.uuid + if let hr = Self.heartRateStats(from: workout) { + session.avgHeartRate = hr.avg + session.minHeartRate = hr.min + session.maxHeartRate = hr.max + } + context.insert(session) + imported += 1 + } + + if imported > 0 { + try? context.save() + counts.workoutsImported = imported + log.info("Imported \(imported) external workout(s) from Apple Health.") + } + } + + /// Total active energy (kcal) recorded for a workout, via the non-deprecated statistics API. + private static func energyKcal(from workout: HKWorkout) -> Double? { + guard let type = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned), + let sum = workout.statistics(for: type)?.sumQuantity() else { return nil } + let kcal = sum.doubleValue(for: .kilocalorie()) + return kcal > 0 ? kcal : nil + } + + /// Total distance (m) for a workout — sums walking/running and cycling so either kind lands. + private static func distanceMeters(from workout: HKWorkout) -> Double? { + var total = 0.0 + for id in [HKQuantityTypeIdentifier.distanceWalkingRunning, .distanceCycling] { + guard let type = HKQuantityType.quantityType(forIdentifier: id), + let sum = workout.statistics(for: type)?.sumQuantity() else { continue } + total += sum.doubleValue(for: .meter()) + } + return total > 0 ? total : nil + } + + /// Average / min / max heart rate (bpm) for a workout, when the source captured HR samples. + private static func heartRateStats(from workout: HKWorkout) -> (avg: Double, min: Double, max: Double)? { + guard let type = HKQuantityType.quantityType(forIdentifier: .heartRate), + let stats = workout.statistics(for: type) else { return nil } + let unit = HKUnit.count().unitDivided(by: .minute()) + guard let avg = stats.averageQuantity()?.doubleValue(for: unit) else { return nil } + let mn = stats.minimumQuantity()?.doubleValue(for: unit) ?? avg + let mx = stats.maximumQuantity()?.doubleValue(for: unit) ?? avg + return (avg, mn, mx) + } + + /// Inverse of `workoutActivityType(for:)` — maps an imported workout's HealthKit activity + /// type onto one of PulseLoop's canonical activity types (see `ActivityMeta.order`). Unknown + /// types fall back to "other" so they still surface in the activity section. + static func activityType(from hkType: HKWorkoutActivityType) -> String { + switch hkType { + case .walking: + return "walk" + case .running: + return "run" + case .cycling, .handCycling: + return "cycle" + case .traditionalStrengthTraining, .functionalStrengthTraining, .coreTraining, + .highIntensityIntervalTraining, .crossTraining: + return "gym" + case .squash: + return "squash" + case .yoga, .flexibility, .mindAndBody, .pilates: + return "yoga" + case .cardioDance, .socialDance, .barre: + return "dance" + case .hiking: + return "hike" + case .soccer, .basketball, .tennis, .americanFootball, .baseball, .volleyball, + .badminton, .tableTennis, .racquetball, .cricket, .hockey, .rugby: + return "sport" + default: + return "other" + } + } +} + +enum HealthSyncError: LocalizedError { + case unavailable + + var errorDescription: String? { + switch self { + case .unavailable: return "Apple Health isn't available on this device." + } + } +} diff --git a/PulseLoop/Models/PulseModels.swift b/PulseLoop/Models/PulseModels.swift index 102d3c5..b1edbb4 100644 --- a/PulseLoop/Models/PulseModels.swift +++ b/PulseLoop/Models/PulseModels.swift @@ -26,7 +26,7 @@ enum MeasurementKind: String, Codable, CaseIterable { case .spo2: return "%" case .stress: return "" case .hrv: return "ms" - case .temperature: return "°C" + case .temperature: return WorkoutAppGroup.useImperialUnits ? "°F" : "°C" } } } @@ -189,6 +189,7 @@ final class Measurement { var confidenceRaw: String var activitySessionId: UUID? var rawPacketId: UUID? + var syncedAt: Date? var createdAt: Date init( @@ -200,7 +201,8 @@ final class Measurement { source: MeasurementSource = .ring, confidence: DecodeConfidence = .known, activitySessionId: UUID? = nil, - rawPacketId: UUID? = nil + rawPacketId: UUID? = nil, + syncedAt: Date? = nil ) { self.id = id self.kindRaw = kind.rawValue @@ -211,6 +213,7 @@ final class Measurement { self.confidenceRaw = confidence.rawValue self.activitySessionId = activitySessionId self.rawPacketId = rawPacketId + self.syncedAt = syncedAt self.createdAt = Date() } @@ -465,6 +468,7 @@ final class ActivitySession { var notes: String? var useGps: Bool var perceivedEffort: String? + var syncedAt: Date? var createdAt: Date var updatedAt: Date @@ -480,6 +484,12 @@ final class ActivitySession { var lastSensorPollAt: Date? var lastGpsPointAt: Date? + /// Set when this session was imported from an Apple Health workout recorded by a *different* + /// app (Whoop, Strava, Apple Watch, …). `nil` for sessions recorded natively in PulseLoop. + /// Stores the source `HKWorkout.uuid` so re-running the import dedupes instead of duplicating, + /// and so the Health *export* path / daily-total netting can exclude rows we only read in. + var healthKitWorkoutID: UUID? = nil + init( id: UUID = UUID(), type: String, @@ -490,7 +500,8 @@ final class ActivitySession { calories: Double? = nil, distanceMeters: Double? = nil, notes: String? = nil, - useGps: Bool = true + useGps: Bool = true, + syncedAt: Date? = nil ) { self.id = id self.type = type @@ -502,6 +513,7 @@ final class ActivitySession { self.distanceMeters = distanceMeters self.notes = notes self.useGps = useGps + self.syncedAt = syncedAt self.createdAt = Date() self.updatedAt = Date() } diff --git a/PulseLoop/PulseLoopApp.swift b/PulseLoop/PulseLoopApp.swift index dda04fe..77607e5 100644 --- a/PulseLoop/PulseLoopApp.swift +++ b/PulseLoop/PulseLoopApp.swift @@ -50,6 +50,12 @@ struct PulseLoopApp: App { // One-time cleanup of activity totals inflated by the old accumulator bug. ActivityService.migrateInflatedActivityIfNeeded(context: container.mainContext) + // Seed the app-group units mirror from the stored profile so the Live Activity widget and + // model-layer helpers format correctly even before the profile editor is opened this launch. + if let profile = ProfileRepository.profile(context: container.mainContext) { + WorkoutAppGroup.useImperialUnits = (profile.units == .imperial) + } + // Don't bring up CoreBluetooth under tests (see `isRunningUnitTests`). let client = RingBLEClient(startManager: !runningTests) let coordinator = RingSyncCoordinator(client: client, context: container.mainContext) diff --git a/PulseLoop/Services/PulseServices.swift b/PulseLoop/Services/PulseServices.swift index ab8ffef..29c2cd9 100644 --- a/PulseLoop/Services/PulseServices.swift +++ b/PulseLoop/Services/PulseServices.swift @@ -702,7 +702,46 @@ enum ActivityService { let spo2 = spo2Rows.map(\.value) let distance = gpsDistance(sessionId: session.id, context: context) ?? session.distanceMeters let duration = max(0, Int(endedAt.timeIntervalSince(session.startedAt) - session.totalPauseSeconds)) - let calories = session.calories ?? max(0, Double(duration) / 60 * 8) + + let calories: Double + if let val = session.calories { + calories = val + } else if UserDefaults(suiteName: WorkoutAppGroup.suite)?.bool(forKey: "useAdvancedCalories") ?? false { + let profile = ProfileRepository.profile(context: context) + let weight = profile?.weightKg ?? 70.0 + let age = profile?.age ?? 30 + let sex = profile?.sex ?? "male" + + if !hr.isEmpty, let avgHR = average(hr) { + let durationMinutes = Double(duration) / 60.0 + let eeKjPerMin: Double + if sex.lowercased() == "female" { + eeKjPerMin = -20.4022 + (0.4472 * avgHR) - (0.1263 * weight) + (0.0740 * Double(age)) + } else { + eeKjPerMin = -55.0969 + (0.6309 * avgHR) + (0.1988 * weight) + (0.2017 * Double(age)) + } + let kcalPerMin = eeKjPerMin / 4.184 + calories = max(0, kcalPerMin * durationMinutes) + } else { + let met: Double + switch session.type.lowercased() { + case "walk": met = 3.5 + case "run": met = 9.8 + case "cycle": met = 7.5 + case "gym": met = 5.0 + case "squash": met = 7.3 + case "sport": met = 6.0 + case "yoga": met = 2.5 + case "dance": met = 5.0 + case "hike": met = 6.0 + default: met = 4.5 + } + let durationHours = Double(duration) / 3600.0 + calories = max(0, met * weight * durationHours) + } + } else { + calories = max(0, Double(duration) / 60 * 8) + } session.endedAt = endedAt session.status = .finished @@ -861,6 +900,7 @@ enum ActivityRecorderService { context.insert(ActivityEvent(sessionId: session.id, kind: "gps_stopped")) context.insert(ActivityEvent(sessionId: session.id, kind: "finished")) try? context.save() + HealthSyncService.shared.triggerAutomaticSync(context: context, delaySeconds: 1.0) } static func cancel(_ session: ActivitySession, context: ModelContext) { diff --git a/PulseLoop/Services/Repositories.swift b/PulseLoop/Services/Repositories.swift index 303bc5a..11c67b0 100644 --- a/PulseLoop/Services/Repositories.swift +++ b/PulseLoop/Services/Repositories.swift @@ -20,6 +20,12 @@ enum MetricsRepository { return (try? context.fetch(descriptor)) ?? [] } + @MainActor + static func unsyncedActivityRows(context: ModelContext) -> [ActivityDaily] { + let descriptor = FetchDescriptor(predicate: #Predicate { $0.syncedAt == nil }, sortBy: [SortDescriptor(\.date)]) + return (try? context.fetch(descriptor)) ?? [] + } + @MainActor static func activityRows(descending context: ModelContext) -> [ActivityDaily] { let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.date, order: .reverse)]) @@ -45,6 +51,14 @@ enum MetricsRepository { return rows.filter { $0.kind == kind } } + @MainActor + static func unsyncedMeasurements(kind: MeasurementKind? = nil, context: ModelContext) -> [Measurement] { + let descriptor = FetchDescriptor(predicate: #Predicate { $0.syncedAt == nil }, sortBy: [SortDescriptor(\.timestamp)]) + let rows = (try? context.fetch(descriptor)) ?? [] + guard let kind else { return rows } + return rows.filter { $0.kind == kind } + } + @MainActor static func latestMeasurement(kind: MeasurementKind, context: ModelContext) -> Measurement? { measurements(kind: kind, context: context).last @@ -97,6 +111,12 @@ enum SleepRepository { return (try? context.fetch(descriptor)) ?? [] } + @MainActor + static func unsyncedSessions(context: ModelContext) -> [SleepSession] { + let descriptor = FetchDescriptor(predicate: #Predicate { $0.syncedAt == nil }, sortBy: [SortDescriptor(\.date)]) + return (try? context.fetch(descriptor)) ?? [] + } + @MainActor static func latestSession(context: ModelContext) -> SleepSession? { let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.startAt, order: .reverse)]) @@ -117,6 +137,12 @@ enum ActivityRepository { return (try? context.fetch(descriptor)) ?? [] } + @MainActor + static func unsyncedSessions(context: ModelContext) -> [ActivitySession] { + let descriptor = FetchDescriptor(predicate: #Predicate { $0.syncedAt == nil }, sortBy: [SortDescriptor(\.startedAt, order: .reverse)]) + return (try? context.fetch(descriptor)) ?? [] + } + @MainActor static func samples(sessionId: UUID, context: ModelContext) -> [ActivitySample] { let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.timestamp)]) diff --git a/PulseLoop/Services/RingSyncCoordinator.swift b/PulseLoop/Services/RingSyncCoordinator.swift index b6d2a66..5aa6111 100644 --- a/PulseLoop/Services/RingSyncCoordinator.swift +++ b/PulseLoop/Services/RingSyncCoordinator.swift @@ -251,6 +251,10 @@ final class RingSyncCoordinator { latestSpO2Value = value case let .spo2Progress(percent, _): if let percent { latestSpO2Value = percent } + case let .historyMeasurement(kind, value, _): + if kind == .spo2 { + latestSpO2Value = Int(value) + } case .deviceStateChanged(.connected, _): lastSyncAt = Date() default: diff --git a/PulseLoop/Shared/WorkoutActivityAttributes.swift b/PulseLoop/Shared/WorkoutActivityAttributes.swift index 18335a2..16e9767 100644 --- a/PulseLoop/Shared/WorkoutActivityAttributes.swift +++ b/PulseLoop/Shared/WorkoutActivityAttributes.swift @@ -49,6 +49,15 @@ nonisolated enum WorkoutAppGroup { static let commandSessionKey = "pendingWorkoutCommandSession" static let commandTimeKey = "pendingWorkoutCommandTime" // Date for de-dupe + /// Mirror of the user's units preference (canonical source is `UserProfile.units`). Kept in the + /// app group so the Live Activity widget extension — which can't read SwiftData — can format + /// distance/pace, and so model-layer helpers have a cheap synchronous read. Written by the app + /// whenever the profile's units change (see `ProfileSettingsView.save`). + static var useImperialUnits: Bool { + get { UserDefaults(suiteName: suite)?.bool(forKey: "useImperialUnits") ?? false } + set { UserDefaults(suiteName: suite)?.set(newValue, forKey: "useImperialUnits") } + } + static func post(_ command: String, sessionID: String) { guard let d = UserDefaults(suiteName: suite) else { return } d.set(command, forKey: commandKey) @@ -133,14 +142,19 @@ enum WorkoutLAColors { static func paceLabel(_ secPerKm: Double?) -> String { guard let secPerKm, secPerKm.isFinite, secPerKm > 0 else { return "—" } - let total = Int(secPerKm.rounded()) + let isImperial = WorkoutAppGroup.useImperialUnits + let factor = isImperial ? 1.60934 : 1.0 + let secPerUnit = secPerKm * factor + let total = Int(secPerUnit.rounded()) let minutes = total / 60 let seconds = total % 60 - return String(format: "%d:%02d /km", minutes, seconds) + return String(format: "%d:%02d /%@", minutes, seconds, isImperial ? "mi" : "km") } static func distanceLabel(_ meters: Double) -> String { guard meters >= 50 else { return "—" } - return String(format: "%.2f km", meters / 1000) + let isImperial = WorkoutAppGroup.useImperialUnits + let divisor = isImperial ? 1609.34 : 1000.0 + return String(format: "%.2f %@", meters / divisor, isImperial ? "mi" : "km") } } diff --git a/PulseLoop/Views/ActivityView.swift b/PulseLoop/Views/ActivityView.swift index c060e75..1b53dba 100644 --- a/PulseLoop/Views/ActivityView.swift +++ b/PulseLoop/Views/ActivityView.swift @@ -18,6 +18,10 @@ struct ActivityView: View { @State private var goalsOpen = false @State private var historyOpen = false + // Numeric divisor for the distance trend graph, driven by the profile's units preference + // (the cards/labels go through UnitsFormatter; the graph needs a raw metres→display number). + private var distanceDivisor: Double { units == .imperial ? 1609.344 : 1000 } + var body: some View { let summary = MetricsService.buildTodaySummary(context: modelContext) let stale = ActivityRecorderService.recoverStaleSession(context: modelContext) @@ -77,7 +81,7 @@ struct ActivityView: View { .overlay(RoundedRectangle(cornerRadius: 20, style: .continuous).stroke(PulseColors.borderSubtle, lineWidth: 1)) } else { ForEach(todayWorkouts) { session in - ActivityWorkoutRow(session: session) { path.append(AppRoute.activityDetail(session.id)) } + ActivityWorkoutRow(session: session, units: units) { path.append(AppRoute.activityDetail(session.id)) } } } } @@ -190,7 +194,7 @@ struct ActivityView: View { } private func distanceValues(_ summary: TodaySummary) -> [Double] { let raw = distanceRange == .sevenDays ? summary.trends.distance7d.map(\.value) : MetricsService.metricRange(metric: .distance, range: distanceRange, context: modelContext).map(\.value) - return raw.map { $0 / 1000 } + return raw.map { $0 / distanceDivisor } } private func caloriesValues(_ summary: TodaySummary) -> [Double] { caloriesRange == .sevenDays ? summary.trends.calories7d.map(\.value) : MetricsService.metricRange(metric: .calories, range: caloriesRange, context: modelContext).map(\.value) @@ -302,8 +306,11 @@ struct GoalEditorSheet: View { struct WorkoutHistorySheet: View { @Environment(\.dismiss) private var dismiss @Query(sort: \ActivitySession.startedAt, order: .reverse) private var sessions: [ActivitySession] + @Query private var profiles: [UserProfile] let onSelect: (UUID) -> Void + private var units: UnitsPreference { profiles.first?.units ?? .metric } + var body: some View { NavigationStack { ScrollView { @@ -313,7 +320,7 @@ struct WorkoutHistorySheet: View { EmptyStateView(title: "No workouts yet", body: "Recorded workouts will appear here.") } else { ForEach(finished) { session in - ActivityWorkoutRow(session: session) { onSelect(session.id) } + ActivityWorkoutRow(session: session, units: units) { onSelect(session.id) } } } } diff --git a/PulseLoop/Views/RecordViews.swift b/PulseLoop/Views/RecordViews.swift index 3c40312..16133bf 100644 --- a/PulseLoop/Views/RecordViews.swift +++ b/PulseLoop/Views/RecordViews.swift @@ -377,7 +377,7 @@ struct RecordLiveView: View { } if showSplits(session) { - SplitStrip(points: points) + SplitStrip(points: points, units: units) } if session.useGps { @@ -535,18 +535,19 @@ func haversineMeters(_ a: ActivityGpsPoint, _ b: ActivityGpsPoint) -> Double { /// Seconds elapsed for each *completed* kilometre of a route, in order. Walks the cumulative /// haversine distance and records the elapsed time every time distance crosses the next km mark. /// Shared by the live `SplitStrip` and the summary `SplitsTable`. -func kmSplitSeconds(_ points: [ActivityGpsPoint]) -> [Double] { +func kmSplitSeconds(_ points: [ActivityGpsPoint], units: UnitsPreference) -> [Double] { guard points.count >= 2, let first = points.first else { return [] } + let divisor = units == .imperial ? 1609.344 : 1000.0 var cumulative = 0.0 var markTime = first.timestamp - var nextKm = 1000.0 + var nextMark = divisor var splits: [Double] = [] for (a, b) in zip(points, points.dropFirst()) { cumulative += haversineMeters(a, b) - while cumulative >= nextKm { + while cumulative >= nextMark { splits.append(b.timestamp.timeIntervalSince(markTime)) markTime = b.timestamp - nextKm += 1000 + nextMark += divisor } } return splits @@ -716,7 +717,7 @@ struct WorkoutMetricsSections: View { if session.useGps { WorkoutMapView(points: points) - SplitsTable(points: accepted) + SplitsTable(points: accepted, units: units) } if hr.count > 1 { @@ -888,19 +889,21 @@ private struct SummaryHeroBand: View { /// there isn't at least one full completed kilometre. private struct SplitsTable: View { let points: [ActivityGpsPoint] + let units: UnitsPreference var body: some View { - let splits = kmSplitSeconds(points) + let splits = kmSplitSeconds(points, units: units) if splits.count >= 1 { let fastest = splits.min() ?? 0 let slowest = splits.max() ?? 1 + let unitLabelCaps = units == .imperial ? "MI" : "KM" VStack(alignment: .leading, spacing: 10) { Text("SPLITS").font(.system(size: 11, weight: .medium)).tracking(1.0).foregroundStyle(PulseColors.textMuted) ForEach(Array(splits.enumerated()), id: \.offset) { index, seconds in let isFastest = seconds == fastest let frac = slowest > fastest ? (seconds - fastest) / (slowest - fastest) : 0 HStack(spacing: 12) { - Text("KM \(index + 1)") + Text("\(unitLabelCaps) \(index + 1)") .font(.system(size: 12, weight: .medium).monospacedDigit()) .foregroundStyle(PulseColors.textSecondary) .frame(width: 44, alignment: .leading) @@ -927,8 +930,9 @@ private struct SplitsTable: View { } } - private func paceLabel(_ secPerKm: Double) -> String { - String(format: "%d:%02d /km", Int(secPerKm) / 60, Int(secPerKm.rounded()) % 60) + private func paceLabel(_ secPerUnit: Double) -> String { + let label = units == .imperial ? "/mi" : "/km" + return String(format: "%d:%02d %@", Int(secPerUnit) / 60, Int(secPerUnit.rounded()) % 60, label) } } @@ -1072,33 +1076,36 @@ struct StatusPill: View { } } -/// Per-kilometre splits for distance activities (last / best / current km pace). +/// Per-kilometre/mile splits for distance activities (last / best / current pace). struct SplitStrip: View { let points: [ActivityGpsPoint] + let units: UnitsPreference var body: some View { let splits = kmSplits() + let unitLabel = units == .imperial ? "mi" : "km" HStack(spacing: 12) { - WorkoutStat(label: "Last km", value: splits.last ?? "—") - WorkoutStat(label: "Best km", value: splits.best ?? "—") - WorkoutStat(label: "This km", value: splits.current ?? "—") + WorkoutStat(label: "Last \(unitLabel)", value: splits.last ?? "—") + WorkoutStat(label: "Best \(unitLabel)", value: splits.best ?? "—") + WorkoutStat(label: "This \(unitLabel)", value: splits.current ?? "—") } } private func kmSplits() -> (last: String?, best: String?, current: String?) { guard points.count >= 2, let lastPoint = points.last else { return (nil, nil, nil) } - let splitSeconds = kmSplitSeconds(points) + let divisor = units == .imperial ? 1609.344 : 1000.0 + let splitSeconds = kmSplitSeconds(points, units: units) let cumulative = zip(points, points.dropFirst()).reduce(0) { $0 + haversineMeters($1.0, $1.1) } - // Partial distance / time since the last whole-km mark. - let distSinceMark = cumulative.truncatingRemainder(dividingBy: 1000) + // Partial distance / time since the last whole-unit mark. + let distSinceMark = cumulative.truncatingRemainder(dividingBy: divisor) let elapsed = lastPoint.timestamp.timeIntervalSince(points.first?.timestamp ?? lastPoint.timestamp) let timeSinceMark = elapsed - splitSeconds.reduce(0, +) - let currentPace = distSinceMark >= 50 && timeSinceMark > 0 ? timeSinceMark / (distSinceMark / 1000) : nil + let currentPace = distSinceMark >= 50 && timeSinceMark > 0 ? timeSinceMark / (distSinceMark / divisor) : nil return (paceString(splitSeconds.last), paceString(splitSeconds.min()), paceString(currentPace)) } - private func paceString(_ secPerKm: Double?) -> String? { - guard let secPerKm, secPerKm > 0 else { return nil } - return String(format: "%d:%02d", Int(secPerKm) / 60, Int(secPerKm.rounded()) % 60) + private func paceString(_ secPerUnit: Double?) -> String? { + guard let secPerUnit, secPerUnit > 0 else { return nil } + return String(format: "%d:%02d", Int(secPerUnit) / 60, Int(secPerUnit.rounded()) % 60) } } @@ -1136,9 +1143,8 @@ struct RecordingQualityCard: View { var rows: [(String, String, Color)] = [] if session.useGps { - let accepted = session.gpsPointCount - let total = accepted + session.rejectedGpsPointCount - let coverage = total > 0 ? Int(Double(accepted) / Double(total) * 100) : 0 + let total = session.gpsPointCount + session.rejectedGpsPointCount + let coverage = total > 0 ? Int(Double(session.gpsPointCount) / Double(total) * 100) : 0 rows.append(("GPS coverage", total > 0 ? "\(coverage)%" : "—", coverage >= 80 ? PulseColors.success : PulseColors.warning)) rows.append(("Dropped GPS points", "\(session.rejectedGpsPointCount)", session.rejectedGpsPointCount == 0 ? PulseColors.textPrimary : PulseColors.warning)) rows.append(("Distance source", session.distanceMeters != nil ? "GPS route" : "—", PulseColors.textPrimary)) diff --git a/PulseLoop/Views/RootViews.swift b/PulseLoop/Views/RootViews.swift index 97b3673..f67c032 100644 --- a/PulseLoop/Views/RootViews.swift +++ b/PulseLoop/Views/RootViews.swift @@ -42,6 +42,7 @@ struct RootAppView: View { if phase == .active { liveWorkout.recover() routeDeepLinkIfNeeded() + HealthSyncService.shared.triggerAutomaticSync(context: modelContext, delaySeconds: 2.0) } } .onOpenURL { url in @@ -80,6 +81,8 @@ struct RootAppView: View { VitalsSettingsView() case .settingsPrivacyData: PrivacyDataSettingsView() + case .settingsHealth: + HealthSettingsDetailView() case .settingsAbout: AboutSettingsView() case .pairing: diff --git a/PulseLoop/Views/Settings/ProfileSettingsView.swift b/PulseLoop/Views/Settings/ProfileSettingsView.swift index c2de773..2dbfeed 100644 --- a/PulseLoop/Views/Settings/ProfileSettingsView.swift +++ b/PulseLoop/Views/Settings/ProfileSettingsView.swift @@ -21,6 +21,11 @@ struct ProfileSettingsView: View { @State private var units: UnitsPreference = .metric @State private var loaded = false + // Apple Health profile import + @State private var isImporting = false + @State private var importMessage: String? + @State private var importSucceeded = false + private let sexOptions: [(value: String, label: String)] = [ ("female", "Female"), ("male", "Male"), ("other", "Other") ] @@ -69,6 +74,29 @@ struct ProfileSettingsView: View { .pickerStyle(.segmented) } + SectionHeader(title: "Apple Health", action: nil) + VStack(alignment: .leading, spacing: 12) { + Text("Import age, sex, height, and weight directly from Apple Health. Name is not syncable due to privacy limits.") + .font(.system(size: 13)) + .foregroundStyle(PulseColors.textSecondary) + .lineSpacing(4) + .frame(maxWidth: .infinity, alignment: .leading) + SecondaryButton( + title: isImporting ? "Syncing…" : "Sync from Apple Health", + systemImage: "arrow.down.heart.fill" + ) { importFromAppleHealth() } + .disabled(isImporting) + if let importMessage { + Text(importMessage) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(importSucceeded ? PulseColors.success : PulseColors.danger) + } + } + .padding(16) + .background(PulseColors.card) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(PulseColors.borderSubtle, lineWidth: 1)) + PrimaryButton(title: "Save profile", systemImage: "checkmark") { save() } } .padding() @@ -156,6 +184,33 @@ struct ProfileSettingsView: View { seedWeightText(for: units) } + /// Pull age / sex / height / weight from HealthKit into the editor's fields (canonical metric; + /// the height picker and weight text reformat to the chosen units). Name is intentionally skipped. + private func importFromAppleHealth() { + isImporting = true + importMessage = nil + Task { @MainActor in + do { + try await HealthSyncService.shared.requestAuthorization() + let data = await HealthSyncService.shared.fetchUserProfileData() + var imported = 0 + if let a = data.age { age = a; imported += 1 } + if let s = data.sex, sexOptions.contains(where: { $0.value == s }) { sex = s; imported += 1 } + if let h = data.heightCm { heightCm = h; imported += 1 } + if let w = data.weightKg { weightKg = w; seedWeightText(for: units); imported += 1 } + isImporting = false + importSucceeded = imported > 0 + importMessage = imported > 0 + ? "Imported \(imported) metric\(imported == 1 ? "" : "s") from Apple Health." + : "No profile data was found in Apple Health." + } catch { + isImporting = false + importSucceeded = false + importMessage = "Health access denied or failed: \(error.localizedDescription)" + } + } + } + private func save() { commitWeightFromText(using: units) let profile = profiles.first ?? { @@ -171,6 +226,9 @@ struct ProfileSettingsView: View { profile.weightKg = weightKg profile.units = units profile.updatedAt = Date() + // Mirror the units preference into the app group so the Live Activity widget (which can't read + // SwiftData) and model-layer helpers format distance/pace/temperature consistently. + WorkoutAppGroup.useImperialUnits = (units == .imperial) try? modelContext.save() // Push the refreshed profile (incl. units) to the connected ring's user-preferences. coordinator.applyUserProfile() diff --git a/PulseLoop/Views/Settings/WorkoutSettingsView.swift b/PulseLoop/Views/Settings/WorkoutSettingsView.swift index 367d568..c641261 100644 --- a/PulseLoop/Views/Settings/WorkoutSettingsView.swift +++ b/PulseLoop/Views/Settings/WorkoutSettingsView.swift @@ -6,6 +6,10 @@ import SwiftUI struct WorkoutSettingsView: View { @State private var store = WorkoutPrefsStore.shared + // App-group flag read by the calorie calc in `ActivityService` (PulseServices). + @AppStorage("useAdvancedCalories", store: UserDefaults(suiteName: WorkoutAppGroup.suite)) + private var useAdvancedCalories = false + private let hrIntervals = [15, 30, 60, 90, 120] private let spo2Intervals = [120, 300, 600] @@ -59,6 +63,15 @@ struct WorkoutSettingsView: View { set: { store.settings.useGpsByDefault = $0 } )) accuracyCard + + SectionHeader(title: "Calories", action: nil) + toggleRow("Use advanced calories", isOn: $useAdvancedCalories) + Text("Calculates energy expenditure using personalized MET values and heart rate " + + "(Keytel formula) instead of a flat 8 kcal/minute estimation.") + .font(.system(size: 12)) + .foregroundStyle(PulseColors.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) } .padding() } diff --git a/PulseLoop/Views/SettingsView.swift b/PulseLoop/Views/SettingsView.swift index 5e88427..32859a9 100644 --- a/PulseLoop/Views/SettingsView.swift +++ b/PulseLoop/Views/SettingsView.swift @@ -88,6 +88,13 @@ struct SettingsView: View { ) { path.append(AppRoute.settingsMeasurement) } } + SettingsCategoryRow( + icon: "heart.fill", + tint: PulseColors.heartRate, + title: "Apple Health", + subtitle: "Connect and sync your ring data to Health" + ) { path.append(AppRoute.settingsHealth) } + SettingsCategoryRow( icon: "lock.shield", tint: PulseColors.success, diff --git a/PulseLoop/Views/TodayView.swift b/PulseLoop/Views/TodayView.swift index f8380dc..386b0a3 100644 --- a/PulseLoop/Views/TodayView.swift +++ b/PulseLoop/Views/TodayView.swift @@ -15,6 +15,8 @@ struct TodayView: View { private var summaryService: CoachSummaryService { CoachSummaryService(modelContext: modelContext) } private var coachEnabled: Bool { coachStore.settings.coachMasterEnabled } private var units: UnitsPreference { profiles.first?.units ?? .metric } + // Raw metres→display divisor for numeric deltas/sparklines (cards/labels use UnitsFormatter). + private var distanceDivisor: Double { units == .imperial ? 1609.344 : 1000 } var body: some View { let summary = MetricsService.buildTodaySummary(context: modelContext) @@ -90,7 +92,7 @@ struct TodayView: View { value: summary.distanceMeters.map { UnitsFormatter.distance(meters: $0, units: units).value } ?? "—", unit: summary.distanceMeters.map { _ in UnitsFormatter.distance(meters: 0, units: units).unit }, color: PulseColors.distance, - delta: TodayInsights.deltaFor(summary, value: summary.distanceMeters.map { $0 / 1000 }, series: summary.trends.distance7d.map { $0.value / 1000 }), + delta: TodayInsights.deltaFor(summary, value: summary.distanceMeters.map { $0 / distanceDivisor }, series: summary.trends.distance7d.map { $0.value / distanceDivisor }), sparkline: summary.trends.distance7d.map(\.value) ) } diff --git a/PulseLoopTests/ActivityServiceTests.swift b/PulseLoopTests/ActivityServiceTests.swift index aac98ff..88843fa 100644 --- a/PulseLoopTests/ActivityServiceTests.swift +++ b/PulseLoopTests/ActivityServiceTests.swift @@ -1,9 +1,33 @@ import XCTest import SwiftData +import HealthKit @testable import PulseLoop @MainActor final class ActivityServiceTests: XCTestCase { + + // MARK: - Apple Health workout import mapping + + /// Imported HealthKit workouts must land on PulseLoop's canonical activity types so they + /// render with the right icon/label in the activity section (and round-trip with the export + /// map). Unknown types fall back to "other" rather than being dropped. + func testHealthKitWorkoutTypeMapping() { + XCTAssertEqual(HealthSyncService.activityType(from: .walking), "walk") + XCTAssertEqual(HealthSyncService.activityType(from: .running), "run") + XCTAssertEqual(HealthSyncService.activityType(from: .cycling), "cycle") + XCTAssertEqual(HealthSyncService.activityType(from: .traditionalStrengthTraining), "gym") + XCTAssertEqual(HealthSyncService.activityType(from: .hiking), "hike") + XCTAssertEqual(HealthSyncService.activityType(from: .cardioDance), "dance") + XCTAssertEqual(HealthSyncService.activityType(from: .yoga), "yoga") + XCTAssertEqual(HealthSyncService.activityType(from: .soccer), "sport") + // Every mapped value must be a type ActivityMeta can render. + let known = Set(ActivityMeta.order) + for hk in [HKWorkoutActivityType.walking, .running, .cycling, .hiking, .swimming, .golf] { + XCTAssertTrue(known.contains(HealthSyncService.activityType(from: hk)), + "mapped type for \(hk.rawValue) should be a known ActivityMeta type") + } + } + func testRatchetOnlyIncreases() throws { let context = try TestSupport.makeContext() let date = Date() @@ -93,4 +117,48 @@ final class ActivityServiceTests: XCTestCase { XCTAssertEqual(summary.durationSeconds, 20 * 60) XCTAssertEqual(session.status, .finished) } + + func testAdvancedCalorieCalculationMET() throws { + let context = try TestSupport.makeContext() + + let defaults = UserDefaults(suiteName: WorkoutAppGroup.suite) + defaults?.set(true, forKey: "useAdvancedCalories") + defer { + defaults?.removeObject(forKey: "useAdvancedCalories") + } + + let profile = UserProfile(age: 30, sex: "male", heightCm: 178, weightKg: 80) + context.insert(profile) + try context.save() + + let session = ActivityRecorderService.start(type: "run", useGps: false, notes: nil, context: context) + let endedAt = session.startedAt.addingTimeInterval(3600) + let summary = ActivityService.finishSummary(for: session, endedAt: endedAt, context: context) + + XCTAssertEqual(summary.calories ?? 0.0, 784.0, accuracy: 0.1) + } + + func testAdvancedCalorieCalculationKeytel() throws { + let context = try TestSupport.makeContext() + + let defaults = UserDefaults(suiteName: WorkoutAppGroup.suite) + defaults?.set(true, forKey: "useAdvancedCalories") + defer { + defaults?.removeObject(forKey: "useAdvancedCalories") + } + + let profile = UserProfile(age: 25, sex: "female", heightCm: 165, weightKg: 60) + context.insert(profile) + try context.save() + + let session = ActivityRecorderService.start(type: "run", useGps: false, notes: nil, context: context) + + let t0 = session.startedAt.addingTimeInterval(30) + TestSupport.insertMeasurement(kind: .heartRate, value: 150, timestamp: t0, source: .live, into: context) + + let endedAt = session.startedAt.addingTimeInterval(30 * 60) + let summary = ActivityService.finishSummary(for: session, endedAt: endedAt, context: context) + + XCTAssertEqual(summary.calories ?? 0.0, 293.6, accuracy: 0.5) + } }