From a1b19e2fd85ad7e912591ce6d217196d476dfa59 Mon Sep 17 00:00:00 2001 From: Trent Hoverman Date: Mon, 22 Jun 2026 17:51:49 -0400 Subject: [PATCH 1/8] feat: integrate Apple Health data sync, fix duplicates, SpO2 timeout, and deep-link --- .../Orchestration/PendingActionExecutor.swift | 2 + PulseLoop/Coach/Tools/ActionTools.swift | 4 +- PulseLoop/Events/PulseEventBus.swift | 34 +- PulseLoop/Health/HealthSettingsSection.swift | 125 ++++ PulseLoop/Health/HealthSyncService.swift | 602 ++++++++++++++++++ PulseLoop/Services/PulseServices.swift | 3 +- PulseLoop/Services/Repositories.swift | 26 + PulseLoop/Services/RingSyncCoordinator.swift | 4 + 8 files changed, 793 insertions(+), 7 deletions(-) create mode 100644 PulseLoop/Health/HealthSettingsSection.swift create mode 100644 PulseLoop/Health/HealthSyncService.swift 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/Events/PulseEventBus.swift b/PulseLoop/Events/PulseEventBus.swift index 485df28..144089b 100644 --- a/PulseLoop/Events/PulseEventBus.swift +++ b/PulseLoop/Events/PulseEventBus.swift @@ -136,7 +136,7 @@ final class EventPersistenceSubscriber { calories: calories, distanceMeters: distanceMeters, source: "live", - syncedAt: Date() + syncedAt: nil ), context: context ) @@ -146,6 +146,7 @@ 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: sum into the day (calories omitted — unverified field). // Reset a day's total only on the *first* bucket seen for it this sync run, so a stalled or @@ -154,24 +155,31 @@ final class EventPersistenceSubscriber { let resetThisDay = !activityDaysResetThisRun.contains(dayKey) if resetThisDay { activityDaysResetThisRun.insert(dayKey) } ActivityService.applyActivityBucket(date: timestamp, steps: steps, distanceMeters: distanceMeters, resetDay: resetThisDay, context: context) + HealthSyncService.shared.triggerAutomaticSync(context: context) case .activitySyncReset: // A fresh ring history sync is starting: clear the per-run reset tracking so each day gets // zeroed once on its first incoming bucket (not all days up front). activityDaysResetThisRun.removeAll() 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, @@ -199,11 +207,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, @@ -216,6 +239,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/HealthSettingsSection.swift b/PulseLoop/Health/HealthSettingsSection.swift new file mode 100644 index 0000000..1ddee0c --- /dev/null +++ b/PulseLoop/Health/HealthSettingsSection.swift @@ -0,0 +1,125 @@ +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..dd39996 --- /dev/null +++ b/PulseLoop/Health/HealthSyncService.swift @@ -0,0 +1,602 @@ +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. +@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) } + return set + } + + // 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). + let finishedSessions = ActivityRepository.sessions(context: context) + .filter { $0.status == .finished && $0.endedAt != nil } + + // Phase 1 — incremental sync of unsynced state. + var counts = SyncCounts() + do { + 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 } + try await syncWorkouts(sessions: unsyncedSessions, 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 + } + } + } + + // 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 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")") } + guard !parts.isEmpty else { return "Nothing to sync yet." } + return "Synced " + parts.joined(separator: ", ") + " to Apple Health." + } + } + + 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 { + w.syncedAt = nil + } + try? context.save() + } +} + +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/Services/PulseServices.swift b/PulseLoop/Services/PulseServices.swift index 87484f4..88a6bcb 100644 --- a/PulseLoop/Services/PulseServices.swift +++ b/PulseLoop/Services/PulseServices.swift @@ -581,7 +581,7 @@ enum ActivityService { /// `resetDay: true` on the first bucket of each day in a sync run (replace, then sum). Calories are /// intentionally not summed (the ring's calorie field is unverified). @discardableResult - static func applyActivityBucket(date: Date, steps: Int, distanceMeters: Double, resetDay: Bool = false, syncedAt: Date = Date(), context: ModelContext) -> ActivityDaily { + static func applyActivityBucket(date: Date, steps: Int, distanceMeters: Double, resetDay: Bool = false, syncedAt: Date? = nil, context: ModelContext) -> ActivityDaily { let row: ActivityDaily if let existing = MetricsRepository.activity(on: date, context: context) { row = existing @@ -790,6 +790,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 975fde8..c8325ed 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 85cb408..a7ce2a0 100644 --- a/PulseLoop/Services/RingSyncCoordinator.swift +++ b/PulseLoop/Services/RingSyncCoordinator.swift @@ -216,6 +216,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: From 95548873be87320f948dccbbbff752fd6fca412a Mon Sep 17 00:00:00 2001 From: Trent Hoverman Date: Tue, 23 Jun 2026 09:09:26 -0400 Subject: [PATCH 2/8] Integrate imperial unit support and Apple Health sync refinements Brings together the working-tree integration of the imperial-units and Apple Health feature work (matching feature/metric-imperial and feature/apple-health) plus related model/service/view updates, so the Enhancements branch builds and runs all features together. Co-Authored-By: Claude Opus 4.8 --- PulseLoop/DesignSystem/Components.swift | 14 +++++++++----- PulseLoop/Models/PulseModels.swift | 12 +++++++++--- PulseLoop/Shared/WorkoutActivityAttributes.swift | 15 ++++++++++++--- PulseLoop/Views/ActivityView.swift | 8 ++++++-- PulseLoop/Views/SettingsView.swift | 7 +++++++ PulseLoop/Views/TodayView.swift | 9 ++++++--- PulseLoop/Views/VitalsView.swift | 7 +++++-- 7 files changed, 54 insertions(+), 18 deletions(-) diff --git a/PulseLoop/DesignSystem/Components.swift b/PulseLoop/DesignSystem/Components.swift index cc3533e..efd652a 100644 --- a/PulseLoop/DesignSystem/Components.swift +++ b/PulseLoop/DesignSystem/Components.swift @@ -589,10 +589,12 @@ enum ActivityMeta { /// Pace in min/km from distance + duration; nil when not meaningful. static func pace(distanceMeters: Double?, durationSeconds: Int?) -> String? { guard let distanceMeters, let durationSeconds, distanceMeters >= 50 else { return nil } - let paceSecPerKm = Double(durationSeconds) / (distanceMeters / 1000) - let m = Int(paceSecPerKm) / 60 - let s = Int(paceSecPerKm.rounded()) % 60 - return String(format: "%d:%02d /km", m, s) + let isImperial = WorkoutAppGroup.useImperialUnits + let factor = isImperial ? 1609.34 : 1000.0 + let paceSecPerUnit = Double(durationSeconds) / (distanceMeters / factor) + let m = Int(paceSecPerUnit) / 60 + let s = Int(paceSecPerUnit.rounded()) % 60 + return String(format: "%d:%02d /%@", m, s, isImperial ? "mi" : "km") } } @@ -624,7 +626,9 @@ 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 isImperial = WorkoutAppGroup.useImperialUnits + let divisor = isImperial ? 1609.34 : 1000.0 + Text(String(format: "%.2f %@", distance / divisor, isImperial ? "mi" : "km")).font(.system(size: 12).monospacedDigit()) } if let hr = session.avgHeartRate { Text("\(Int(hr)) bpm avg").font(.system(size: 12).monospacedDigit()) diff --git a/PulseLoop/Models/PulseModels.swift b/PulseLoop/Models/PulseModels.swift index 8d3a309..e1ec830 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() } @@ -414,6 +417,7 @@ final class ActivitySession { var notes: String? var useGps: Bool var perceivedEffort: String? + var syncedAt: Date? var createdAt: Date var updatedAt: Date @@ -439,7 +443,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 @@ -451,6 +456,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/Shared/WorkoutActivityAttributes.swift b/PulseLoop/Shared/WorkoutActivityAttributes.swift index 18335a2..99016e1 100644 --- a/PulseLoop/Shared/WorkoutActivityAttributes.swift +++ b/PulseLoop/Shared/WorkoutActivityAttributes.swift @@ -49,6 +49,10 @@ nonisolated enum WorkoutAppGroup { static let commandSessionKey = "pendingWorkoutCommandSession" static let commandTimeKey = "pendingWorkoutCommandTime" // Date for de-dupe + static var useImperialUnits: Bool { + UserDefaults(suiteName: suite)?.bool(forKey: "useImperialUnits") ?? false + } + static func post(_ command: String, sessionID: String) { guard let d = UserDefaults(suiteName: suite) else { return } d.set(command, forKey: commandKey) @@ -133,14 +137,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 ab7b72c..38695cc 100644 --- a/PulseLoop/Views/ActivityView.swift +++ b/PulseLoop/Views/ActivityView.swift @@ -15,6 +15,10 @@ struct ActivityView: View { @State private var goalsOpen = false @State private var historyOpen = false + private var isImperial: Bool { WorkoutAppGroup.useImperialUnits } + private var distanceDivisor: Double { isImperial ? 1609.34 : 1000.0 } + private var distanceUnit: String { isImperial ? "mi" : "km" } + var body: some View { let summary = MetricsService.buildTodaySummary(context: modelContext) let stale = ActivityRecorderService.recoverStaleSession(context: modelContext) @@ -111,7 +115,7 @@ struct ActivityView: View { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { MetricCardButton(metric: "steps", label: "Steps", value: summary.steps.map { $0.formatted() } ?? "—", color: PulseColors.steps) MetricCardButton(metric: "calories", label: "Calories", value: summary.calories.map { Int($0).formatted() } ?? "—", unit: summary.calories == nil ? nil : "kcal", color: PulseColors.calories) - MetricCardButton(metric: "distance", label: "Distance", value: summary.distanceMeters.map { String(format: "%.2f", $0 / 1000) } ?? "—", unit: summary.distanceMeters == nil ? nil : "km", color: PulseColors.distance) + MetricCardButton(metric: "distance", label: "Distance", value: summary.distanceMeters.map { String(format: "%.2f", $0 / distanceDivisor) } ?? "—", unit: summary.distanceMeters == nil ? nil : distanceUnit, color: PulseColors.distance) MetricCardButton(metric: "readiness", label: "Active min", value: summary.activeMinutes.map { "\($0)" } ?? "—", unit: summary.activeMinutes == nil ? nil : "min", color: PulseColors.readiness) } @@ -164,7 +168,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) diff --git a/PulseLoop/Views/SettingsView.swift b/PulseLoop/Views/SettingsView.swift index 9cd928e..c3c632f 100644 --- a/PulseLoop/Views/SettingsView.swift +++ b/PulseLoop/Views/SettingsView.swift @@ -10,6 +10,9 @@ struct SettingsView: View { @Binding var path: NavigationPath @State private var diagnosticsURL: URL? + @AppStorage("useImperialUnits", store: UserDefaults(suiteName: WorkoutAppGroup.suite)) + private var useImperialUnits = false + private var appVersionLabel: String { let v = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?" let b = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?" @@ -51,6 +54,10 @@ struct SettingsView: View { CoachSettingsSection() + SectionHeader(title: "Preferences", action: nil) + Toggle("Use Imperial Units", isOn: $useImperialUnits) + .tint(PulseColors.accent) + SectionHeader(title: "About", action: nil) StatusCopy(title: "Version", body: appVersionLabel) SecondaryButton(title: "Export diagnostics", systemImage: "square.and.arrow.up") { diff --git a/PulseLoop/Views/TodayView.swift b/PulseLoop/Views/TodayView.swift index 1014a8a..6f08c7b 100644 --- a/PulseLoop/Views/TodayView.swift +++ b/PulseLoop/Views/TodayView.swift @@ -13,6 +13,9 @@ struct TodayView: View { private var summaryService: CoachSummaryService { CoachSummaryService(modelContext: modelContext) } private var coachEnabled: Bool { coachStore.settings.coachMasterEnabled } + private var isImperial: Bool { WorkoutAppGroup.useImperialUnits } + private var distanceDivisor: Double { isImperial ? 1609.34 : 1000.0 } + private var distanceUnit: String { isImperial ? "mi" : "km" } var body: some View { let summary = MetricsService.buildTodaySummary(context: modelContext) @@ -79,10 +82,10 @@ struct TodayView: View { ) MetricCardButton( metric: "distance", label: "Distance", - value: summary.distanceMeters.map { String(format: "%.2f", $0 / 1000) } ?? "—", - unit: summary.distanceMeters == nil ? nil : "km", + value: summary.distanceMeters.map { String(format: "%.2f", $0 / distanceDivisor) } ?? "—", + unit: summary.distanceMeters == nil ? nil : distanceUnit, 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/PulseLoop/Views/VitalsView.swift b/PulseLoop/Views/VitalsView.swift index fc7b301..bd15df8 100644 --- a/PulseLoop/Views/VitalsView.swift +++ b/PulseLoop/Views/VitalsView.swift @@ -111,11 +111,14 @@ struct VitalsView: View { if MetricsService.supports(.temperature, context: modelContext) { DetailCard(title: "Skin temperature", color: PulseColors.temperature) { - let label = tempSamples.last.map { String(format: "%.1f", $0.value) } ?? "--" + let isImperial = WorkoutAppGroup.useImperialUnits + let tempFormatter: (Double) -> Double = { isImperial ? ($0 * 9/5 + 32) : $0 } + let unitStr = isImperial ? "°F" : "°C" + let label = tempSamples.last.map { String(format: "%.1f", tempFormatter($0.value)) } ?? "--" HStack(alignment: .firstTextBaseline, spacing: 6) { Text(label).font(.system(size: 40, weight: .semibold)).monospacedDigit().foregroundStyle(PulseColors.textPrimary) if !tempSamples.isEmpty { - Text("°C").font(.system(size: 14, weight: .medium)).foregroundStyle(PulseColors.textMuted) + Text(unitStr).font(.system(size: 14, weight: .medium)).foregroundStyle(PulseColors.textMuted) } } .padding(.top, 12) From 907ed91866f103ba6c89a7000d133ddb2c156660 Mon Sep 17 00:00:00 2001 From: Trent Hoverman Date: Tue, 23 Jun 2026 10:54:17 -0400 Subject: [PATCH 3/8] feat: Add profile editor, HealthKit profile sync, and advanced calorie calculations --- PulseLoop/App/AppTheme.swift | 1 + PulseLoop/Health/HealthSyncService.swift | 61 +++ PulseLoop/Services/PulseServices.swift | 41 +- .../Shared/WorkoutActivityAttributes.swift | 8 +- PulseLoop/Views/ProfileEditView.swift | 355 ++++++++++++++++++ PulseLoop/Views/RecordViews.swift | 59 +-- PulseLoop/Views/RootViews.swift | 2 + PulseLoop/Views/SettingsView.swift | 77 +++- PulseLoopTests/ActivityServiceTests.swift | 44 +++ 9 files changed, 623 insertions(+), 25 deletions(-) create mode 100644 PulseLoop/Views/ProfileEditView.swift diff --git a/PulseLoop/App/AppTheme.swift b/PulseLoop/App/AppTheme.swift index ab45b05..cfcf7b0 100644 --- a/PulseLoop/App/AppTheme.swift +++ b/PulseLoop/App/AppTheme.swift @@ -9,6 +9,7 @@ enum AppRoute: Hashable { case pairing case debug case componentGallery + case profileEdit } enum MainTab: String, CaseIterable, Identifiable { diff --git a/PulseLoop/Health/HealthSyncService.swift b/PulseLoop/Health/HealthSyncService.swift index dd39996..c492ff3 100644 --- a/PulseLoop/Health/HealthSyncService.swift +++ b/PulseLoop/Health/HealthSyncService.swift @@ -73,9 +73,70 @@ final class HealthSyncService { 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 diff --git a/PulseLoop/Services/PulseServices.swift b/PulseLoop/Services/PulseServices.swift index 88a6bcb..0285ea1 100644 --- a/PulseLoop/Services/PulseServices.swift +++ b/PulseLoop/Services/PulseServices.swift @@ -631,7 +631,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 diff --git a/PulseLoop/Shared/WorkoutActivityAttributes.swift b/PulseLoop/Shared/WorkoutActivityAttributes.swift index 99016e1..9163ddc 100644 --- a/PulseLoop/Shared/WorkoutActivityAttributes.swift +++ b/PulseLoop/Shared/WorkoutActivityAttributes.swift @@ -44,7 +44,13 @@ struct WorkoutActivityAttributes: ActivityAttributes { /// `nonisolated` so the App Intents' nonisolated `perform()` can call it under Swift 6 /// (this target defaults to MainActor isolation). nonisolated enum WorkoutAppGroup { - static let suite = "group.xyz.sakshambhutani.pulseloop2" + static var suite: String { + let bid = Bundle.main.bundleIdentifier ?? "" + if bid.contains("hov.PulseLoopHov") { + return "group.hov.PulseLoopHov.app" + } + return "group.xyz.sakshambhutani.pulseloop2" + } static let commandKey = "pendingWorkoutCommand" // "pause" | "resume" | "finish" static let commandSessionKey = "pendingWorkoutCommandSession" static let commandTimeKey = "pendingWorkoutCommandTime" // Date for de-dupe diff --git a/PulseLoop/Views/ProfileEditView.swift b/PulseLoop/Views/ProfileEditView.swift new file mode 100644 index 0000000..6d0f930 --- /dev/null +++ b/PulseLoop/Views/ProfileEditView.swift @@ -0,0 +1,355 @@ +import SwiftUI +import SwiftData +import HealthKit + +struct ProfileEditView: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + @Query private var profiles: [UserProfile] + @Binding var path: NavigationPath + + @State private var nameText = "" + @State private var ageText = "" + @State private var selectedSex = "not set" + + // Metric height + @State private var heightCmText = "" + + // Imperial height + @State private var heightFeetText = "" + @State private var heightInchesText = "" + + // Weight (displayed as kg or lbs based on setting) + @State private var weightText = "" + + // UI state + @State private var importErrorMessage: String? = nil + @State private var importSuccessMessage: String? = nil + @State private var isImporting = false + + private var useImperialUnits: Bool { + WorkoutAppGroup.useImperialUnits + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + + // Form Container + VStack(alignment: .leading, spacing: 18) { + + ProfileInputField(label: "Name", placeholder: "Enter your name", text: $nameText) + + HStack(spacing: 16) { + ProfileInputField(label: "Age", placeholder: "Age", text: $ageText, keyboardType: .numberPad) + .frame(maxWidth: 120) + + VStack(alignment: .leading, spacing: 6) { + Text("Sex") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(PulseColors.textSecondary) + .textCase(.uppercase) + + Picker("Sex", selection: $selectedSex) { + Text("Not Set").tag("not set") + Text("Male").tag("male") + Text("Female").tag("female") + Text("Other").tag("other") + } + .pickerStyle(.segmented) + .frame(height: 44) + } + } + + // Height input based on imperial setting + if useImperialUnits { + VStack(alignment: .leading, spacing: 6) { + Text("Height") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(PulseColors.textSecondary) + .textCase(.uppercase) + + HStack(spacing: 12) { + HStack { + TextField("FT", text: $heightFeetText) + .keyboardType(.numberPad) + .multilineTextAlignment(.center) + .foregroundStyle(PulseColors.textPrimary) + Text("ft") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(PulseColors.textMuted) + } + .padding(.horizontal, 12) + .padding(.vertical, 12) + .background(PulseColors.cardSoft, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(PulseColors.borderSubtle, lineWidth: 1)) + + HStack { + TextField("IN", text: $heightInchesText) + .keyboardType(.numberPad) + .multilineTextAlignment(.center) + .foregroundStyle(PulseColors.textPrimary) + Text("in") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(PulseColors.textMuted) + } + .padding(.horizontal, 12) + .padding(.vertical, 12) + .background(PulseColors.cardSoft, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(PulseColors.borderSubtle, lineWidth: 1)) + } + } + } else { + VStack(alignment: .leading, spacing: 6) { + Text("Height") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(PulseColors.textSecondary) + .textCase(.uppercase) + + HStack { + TextField("Height", text: $heightCmText) + .keyboardType(.decimalPad) + .foregroundStyle(PulseColors.textPrimary) + Spacer() + Text("cm") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(PulseColors.textMuted) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(PulseColors.cardSoft, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(PulseColors.borderSubtle, lineWidth: 1)) + } + } + + // Weight input based on imperial setting + VStack(alignment: .leading, spacing: 6) { + Text("Weight") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(PulseColors.textSecondary) + .textCase(.uppercase) + + HStack { + TextField("Weight", text: $weightText) + .keyboardType(.decimalPad) + .foregroundStyle(PulseColors.textPrimary) + Spacer() + Text(useImperialUnits ? "lbs" : "kg") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(PulseColors.textMuted) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(PulseColors.cardSoft, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(PulseColors.borderSubtle, lineWidth: 1)) + } + } + .padding(20) + .background(PulseColors.card, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 24, style: .continuous).stroke(PulseColors.borderSubtle, lineWidth: 1)) + + // Apple Health Integration Card + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 8) { + Image(systemName: "heart.text.square.fill") + .font(.system(size: 20)) + .foregroundStyle(.red) + Text("Apple Health Sync") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(PulseColors.textPrimary) + } + + 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) + + SecondaryButton( + title: isImporting ? "Syncing..." : "Sync from Apple Health", + systemImage: "arrow.down.heart.fill" + ) { + importFromAppleHealth() + } + .disabled(isImporting) + + if let success = importSuccessMessage { + Text(success) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(PulseColors.success) + } + + if let error = importErrorMessage { + Text(error) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.red) + } + } + .padding(20) + .background(PulseColors.card, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 24, style: .continuous).stroke(PulseColors.borderSubtle, lineWidth: 1)) + + // Save Button + PrimaryButton(title: "Save Profile", systemImage: "checkmark.circle.fill") { + saveProfile() + } + .padding(.top, 8) + } + .padding() + } + .background(PulseColors.background.ignoresSafeArea()) + .navigationTitle("Edit Profile") + .onAppear(perform: loadProfileData) + } + + // MARK: - Helper Methods + + private func loadProfileData() { + guard let profile = profiles.first else { return } + nameText = profile.name ?? "" + ageText = profile.age.map { "\($0)" } ?? "" + selectedSex = profile.sex ?? "not set" + + if let h = profile.heightCm { + if useImperialUnits { + let totalInches = h / 2.54 + let ft = Int(totalInches) / 12 + let inch = Int(totalInches.rounded()) % 12 + heightFeetText = "\(ft)" + heightInchesText = "\(inch)" + } else { + heightCmText = String(format: "%.1f", h) + } + } + + if let w = profile.weightKg { + if useImperialUnits { + let lbs = w * 2.20462 + weightText = String(format: "%.1f", lbs) + } else { + weightText = String(format: "%.1f", w) + } + } + } + + private func importFromAppleHealth() { + isImporting = true + importErrorMessage = nil + importSuccessMessage = nil + + Task { @MainActor in + do { + try await HealthSyncService.shared.requestAuthorization() + let data = await HealthSyncService.shared.fetchUserProfileData() + + var importedCount = 0 + + if let age = data.age { + ageText = "\(age)" + importedCount += 1 + } + if let sex = data.sex { + selectedSex = sex + importedCount += 1 + } + + if let heightCm = data.heightCm { + importedCount += 1 + if useImperialUnits { + let totalInches = heightCm / 2.54 + let ft = Int(totalInches) / 12 + let inch = Int(totalInches.rounded()) % 12 + heightFeetText = "\(ft)" + heightInchesText = "\(inch)" + } else { + heightCmText = String(format: "%.1f", heightCm) + } + } + + if let weightKg = data.weightKg { + importedCount += 1 + if useImperialUnits { + let lbs = weightKg * 2.20462 + weightText = String(format: "%.1f", lbs) + } else { + weightText = String(format: "%.1f", weightKg) + } + } + + isImporting = false + if importedCount > 0 { + importSuccessMessage = "Successfully imported \(importedCount) metric(s) from Apple Health!" + } else { + importErrorMessage = "No profile data was found in Apple Health." + } + } catch { + isImporting = false + importErrorMessage = "Health access denied or failed: \(error.localizedDescription)" + } + } + } + + private func saveProfile() { + let profile = profiles.first ?? UserProfile() + profile.name = nameText.trimmingCharacters(in: .whitespacesAndNewlines) + profile.age = Int(ageText) + profile.sex = selectedSex + + // Height Conversion & Saving + if useImperialUnits { + let ft = Double(heightFeetText) ?? 0.0 + let inch = Double(heightInchesText) ?? 0.0 + if ft > 0 || inch > 0 { + profile.heightCm = (ft * 12 + inch) * 2.54 + } + } else { + if let h = Double(heightCmText) { + profile.heightCm = h + } + } + + // Weight Conversion & Saving + if let w = Double(weightText) { + if useImperialUnits { + profile.weightKg = w / 2.20462 + } else { + profile.weightKg = w + } + } + + profile.updatedAt = Date() + + if profiles.isEmpty { + modelContext.insert(profile) + } + + try? modelContext.save() + dismiss() + } +} + +// MARK: - Input Field Component + +struct ProfileInputField: View { + let label: String + let placeholder: String + @Binding var text: String + var keyboardType: UIKeyboardType = .default + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(label) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(PulseColors.textSecondary) + .textCase(.uppercase) + + TextField(placeholder, text: $text) + .keyboardType(keyboardType) + .font(.system(size: 15)) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(PulseColors.cardSoft, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(PulseColors.borderSubtle, lineWidth: 1)) + .foregroundStyle(PulseColors.textPrimary) + } + } +} diff --git a/PulseLoop/Views/RecordViews.swift b/PulseLoop/Views/RecordViews.swift index 0a8a781..75db656 100644 --- a/PulseLoop/Views/RecordViews.swift +++ b/PulseLoop/Views/RecordViews.swift @@ -141,7 +141,9 @@ struct WorkoutRow: View { .font(.caption) .foregroundStyle(PulseColors.textSecondary) if let distance = session.distanceMeters { - Text(String(format: "%.2f km", distance / 1000)) + let isImperial = WorkoutAppGroup.useImperialUnits + let divisor = isImperial ? 1609.34 : 1000.0 + Text(String(format: "%.2f %@", distance / divisor, isImperial ? "mi" : "km")) .font(.caption.monospacedDigit()) .foregroundStyle(PulseColors.textMuted) } @@ -492,7 +494,10 @@ struct RecordLiveView: View { private func distanceLabel(points: [ActivityGpsPoint], session: ActivitySession) -> String { guard session.useGps else { return "—" } let meters = routeDistance(points) - return meters > 0 ? String(format: "%.2f km", meters / 1000) : "—" + guard meters > 0 else { return "—" } + let isImperial = WorkoutAppGroup.useImperialUnits + let divisor = isImperial ? 1609.34 : 1000.0 + return String(format: "%.2f %@", meters / divisor, isImperial ? "mi" : "km") } private func paceLabel(points: [ActivityGpsPoint], elapsedSec: Int, session: ActivitySession) -> String { @@ -527,16 +532,18 @@ func haversineMeters(_ a: ActivityGpsPoint, _ b: ActivityGpsPoint) -> Double { /// Shared by the live `SplitStrip` and the summary `SplitsTable`. func kmSplitSeconds(_ points: [ActivityGpsPoint]) -> [Double] { guard points.count >= 2, let first = points.first else { return [] } + let isImperial = WorkoutAppGroup.useImperialUnits + let divisor = isImperial ? 1609.34 : 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 @@ -826,12 +833,14 @@ private struct SummaryHeroBand: View { private var metrics: [Metric] { let dur = durationSeconds.map { ActivityMeta.duration($0) } ?? "—" if session.useGps { - let dist = session.distanceMeters.map { String(format: "%.2f", $0 / 1000) } ?? "—" + let isImperial = WorkoutAppGroup.useImperialUnits + let divisor = isImperial ? 1609.34 : 1000.0 + let dist = session.distanceMeters.map { String(format: "%.2f", $0 / divisor) } ?? "—" let pace = ActivityMeta.pace(distanceMeters: session.distanceMeters, durationSeconds: durationSeconds) return [ - Metric(value: dist, label: "KM", tint: PulseColors.distance), + Metric(value: dist, label: isImperial ? "MI" : "KM", tint: PulseColors.distance), Metric(value: dur, label: "DURATION", tint: PulseColors.textPrimary), - Metric(value: pace?.replacingOccurrences(of: " /km", with: "") ?? "—", label: "PACE /KM", tint: PulseColors.accent) + Metric(value: pace?.replacingOccurrences(of: " /km", with: "").replacingOccurrences(of: " /mi", with: "") ?? "—", label: isImperial ? "PACE /MI" : "PACE /KM", tint: PulseColors.accent) ] } else { let cals = session.calories.map { "\(Int($0))" } ?? "—" @@ -879,13 +888,15 @@ private struct SplitsTable: View { if splits.count >= 1 { let fastest = splits.min() ?? 0 let slowest = splits.max() ?? 1 + let isImperial = WorkoutAppGroup.useImperialUnits + let unitLabelCaps = isImperial ? "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) @@ -912,8 +923,10 @@ 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 isImperial = WorkoutAppGroup.useImperialUnits + let label = isImperial ? "/mi" : "/km" + return String(format: "%d:%02d %@", Int(secPerUnit) / 60, Int(secPerUnit.rounded()) % 60, label) } } @@ -1057,33 +1070,37 @@ 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] var body: some View { let splits = kmSplits() + let isImperial = WorkoutAppGroup.useImperialUnits + let unitLabel = isImperial ? "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 isImperial = WorkoutAppGroup.useImperialUnits + let divisor = isImperial ? 1609.34 : 1000.0 let splitSeconds = kmSplitSeconds(points) 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) } } diff --git a/PulseLoop/Views/RootViews.swift b/PulseLoop/Views/RootViews.swift index ed2d2c7..85413ab 100644 --- a/PulseLoop/Views/RootViews.swift +++ b/PulseLoop/Views/RootViews.swift @@ -68,6 +68,8 @@ struct RootAppView: View { DebugView() case .componentGallery: ComponentGalleryView() + case .profileEdit: + ProfileEditView(path: $path) } } } diff --git a/PulseLoop/Views/SettingsView.swift b/PulseLoop/Views/SettingsView.swift index c3c632f..70d6507 100644 --- a/PulseLoop/Views/SettingsView.swift +++ b/PulseLoop/Views/SettingsView.swift @@ -13,6 +13,9 @@ struct SettingsView: View { @AppStorage("useImperialUnits", store: UserDefaults(suiteName: WorkoutAppGroup.suite)) private var useImperialUnits = false + @AppStorage("useAdvancedCalories", store: UserDefaults(suiteName: WorkoutAppGroup.suite)) + private var useAdvancedCalories = false + private var appVersionLabel: String { let v = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?" let b = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?" @@ -29,8 +32,56 @@ struct SettingsView: View { var body: some View { ScrollView { VStack(spacing: 16) { - SectionHeader(title: "Profile", action: nil) - StatusCopy(title: "Name", body: profiles.first?.name ?? "Not set") + SectionHeader(title: "Profile", action: "Edit") + .onTapGesture { + path.append(AppRoute.profileEdit) + } + + Button { + path.append(AppRoute.profileEdit) + } label: { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(profiles.first?.name ?? "Not set") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(PulseColors.textPrimary) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(PulseColors.textMuted) + } + + if let profile = profiles.first, profile.age != nil || profile.heightCm != nil || profile.weightKg != nil { + HStack(spacing: 16) { + if let age = profile.age { + VStack(alignment: .leading, spacing: 2) { + Text("Age").font(.system(size: 10, weight: .bold)).foregroundStyle(PulseColors.textMuted).textCase(.uppercase) + Text("\(age) yrs").font(.system(size: 14, weight: .medium)).foregroundStyle(PulseColors.textSecondary) + } + } + + if let h = profile.heightCm { + VStack(alignment: .leading, spacing: 2) { + Text("Height").font(.system(size: 10, weight: .bold)).foregroundStyle(PulseColors.textMuted).textCase(.uppercase) + Text(heightDisplayString(h)).font(.system(size: 14, weight: .medium)).foregroundStyle(PulseColors.textSecondary) + } + } + + if let w = profile.weightKg { + VStack(alignment: .leading, spacing: 2) { + Text("Weight").font(.system(size: 10, weight: .bold)).foregroundStyle(PulseColors.textMuted).textCase(.uppercase) + Text(weightDisplayString(w)).font(.system(size: 14, weight: .medium)).foregroundStyle(PulseColors.textSecondary) + } + } + } + .padding(.top, 4) + } + } + .padding(16) + .background(PulseColors.card, in: RoundedRectangle(cornerRadius: 16)) + .overlay(RoundedRectangle(cornerRadius: 16).stroke(PulseColors.borderSubtle, lineWidth: 1)) + } + .buttonStyle(.plain) SectionHeader(title: "Ring", action: nil) if ble.state == .connected { @@ -57,6 +108,8 @@ struct SettingsView: View { SectionHeader(title: "Preferences", action: nil) Toggle("Use Imperial Units", isOn: $useImperialUnits) .tint(PulseColors.accent) + Toggle("Use Advanced Calories", isOn: $useAdvancedCalories) + .tint(PulseColors.accent) SectionHeader(title: "About", action: nil) StatusCopy(title: "Version", body: appVersionLabel) @@ -96,6 +149,26 @@ struct SettingsView: View { DiagnosticsShareSheet(items: [url]) } } + + private func heightDisplayString(_ cm: Double) -> String { + if useImperialUnits { + let totalInches = cm / 2.54 + let ft = Int(totalInches) / 12 + let inch = Int(totalInches.rounded()) % 12 + return "\(ft)'\(inch)\"" + } else { + return String(format: "%.0f cm", cm) + } + } + + private func weightDisplayString(_ kg: Double) -> String { + if useImperialUnits { + let lbs = kg * 2.20462 + return String(format: "%.0f lbs", lbs) + } else { + return String(format: "%.1f kg", kg) + } + } } extension URL: @retroactive Identifiable { diff --git a/PulseLoopTests/ActivityServiceTests.swift b/PulseLoopTests/ActivityServiceTests.swift index 8086cca..767430b 100644 --- a/PulseLoopTests/ActivityServiceTests.swift +++ b/PulseLoopTests/ActivityServiceTests.swift @@ -63,4 +63,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) + } } From e8a1ea963ae39f4bedd7f81c5694a94e8b7862ff Mon Sep 17 00:00:00 2001 From: Trent Hoverman Date: Tue, 23 Jun 2026 11:12:21 -0400 Subject: [PATCH 4/8] feat: Automate profile sync from Apple Health on app active & add advanced calories subtext --- PulseLoop/Health/HealthSyncService.swift | 30 ++++++++++++++++++++++++ PulseLoop/Views/RootViews.swift | 1 + PulseLoop/Views/SettingsView.swift | 11 +++++++-- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/PulseLoop/Health/HealthSyncService.swift b/PulseLoop/Health/HealthSyncService.swift index c492ff3..def93ae 100644 --- a/PulseLoop/Health/HealthSyncService.swift +++ b/PulseLoop/Health/HealthSyncService.swift @@ -177,6 +177,7 @@ final class HealthSyncService { // 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) @@ -218,6 +219,35 @@ final class HealthSyncService { } } + 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 { diff --git a/PulseLoop/Views/RootViews.swift b/PulseLoop/Views/RootViews.swift index 85413ab..d1259d6 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 diff --git a/PulseLoop/Views/SettingsView.swift b/PulseLoop/Views/SettingsView.swift index 70d6507..20c776e 100644 --- a/PulseLoop/Views/SettingsView.swift +++ b/PulseLoop/Views/SettingsView.swift @@ -108,8 +108,15 @@ struct SettingsView: View { SectionHeader(title: "Preferences", action: nil) Toggle("Use Imperial Units", isOn: $useImperialUnits) .tint(PulseColors.accent) - Toggle("Use Advanced Calories", isOn: $useAdvancedCalories) - .tint(PulseColors.accent) + + VStack(alignment: .leading, spacing: 4) { + Toggle("Use Advanced Calories", isOn: $useAdvancedCalories) + .tint(PulseColors.accent) + Text("Calculates energy expenditure using personalized MET values and heart rate (Keytel formula) instead of a flat 8 kcal/minute estimation.") + .font(.system(size: 11)) + .foregroundStyle(PulseColors.textMuted) + .padding(.horizontal, 4) + } SectionHeader(title: "About", action: nil) StatusCopy(title: "Version", body: appVersionLabel) From 8fd791dfcb10fbd970c995227292b5b8c47fe15d Mon Sep 17 00:00:00 2001 From: Trent Hoverman Date: Thu, 25 Jun 2026 12:15:59 -0400 Subject: [PATCH 5/8] chore: use upstream app-group id (drop fork-specific fallback) --- PulseLoop/Shared/WorkoutActivityAttributes.swift | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/PulseLoop/Shared/WorkoutActivityAttributes.swift b/PulseLoop/Shared/WorkoutActivityAttributes.swift index f7f9e91..16e9767 100644 --- a/PulseLoop/Shared/WorkoutActivityAttributes.swift +++ b/PulseLoop/Shared/WorkoutActivityAttributes.swift @@ -44,13 +44,7 @@ struct WorkoutActivityAttributes: ActivityAttributes { /// `nonisolated` so the App Intents' nonisolated `perform()` can call it under Swift 6 /// (this target defaults to MainActor isolation). nonisolated enum WorkoutAppGroup { - static var suite: String { - let bid = Bundle.main.bundleIdentifier ?? "" - if bid.contains("hov.PulseLoopHov") { - return "group.hov.PulseLoopHov.app" - } - return "group.xyz.sakshambhutani.pulseloop2" - } + static let suite = "group.xyz.sakshambhutani.pulseloop2" static let commandKey = "pendingWorkoutCommand" // "pause" | "resume" | "finish" static let commandSessionKey = "pendingWorkoutCommandSession" static let commandTimeKey = "pendingWorkoutCommandTime" // Date for de-dupe From 55eacaa1786c0b70015356b83995e0f0127aaf09 Mon Sep 17 00:00:00 2001 From: Trent Hoverman Date: Thu, 25 Jun 2026 16:20:23 -0400 Subject: [PATCH 6/8] feat: surface Apple Health settings screen (wire up HealthSettingsSection) HealthSettingsSection (connection status, authorization, and 'Sync workouts history') existed but was never rendered. Add an 'Apple Health' settings row + settingsHealth route + HealthSettingsDetailView wrapper, matching the CoachSettingsDetailView idiom. Co-Authored-By: Claude Opus 4.8 --- PulseLoop/App/AppTheme.swift | 1 + PulseLoop/Health/HealthSettingsDetailView.swift | 16 ++++++++++++++++ PulseLoop/Views/RootViews.swift | 2 ++ PulseLoop/Views/SettingsView.swift | 7 +++++++ 4 files changed, 26 insertions(+) create mode 100644 PulseLoop/Health/HealthSettingsDetailView.swift 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/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/Views/RootViews.swift b/PulseLoop/Views/RootViews.swift index 729a803..f67c032 100644 --- a/PulseLoop/Views/RootViews.swift +++ b/PulseLoop/Views/RootViews.swift @@ -81,6 +81,8 @@ struct RootAppView: View { VitalsSettingsView() case .settingsPrivacyData: PrivacyDataSettingsView() + case .settingsHealth: + HealthSettingsDetailView() case .settingsAbout: AboutSettingsView() case .pairing: 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, From 6df6492c2af38588d02a5f7e1d06f1ebeeec0ce8 Mon Sep 17 00:00:00 2001 From: Trent Hoverman Date: Tue, 30 Jun 2026 09:50:22 -0400 Subject: [PATCH 7/8] feat: import Apple Health workouts from other apps into activity section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HealthSyncService was export-only. Add an import path so workouts logged by other apps (Apple Watch, Whoop, Strava, …) and stored in Apple Health appear in PulseLoop's activity/workouts section alongside natively recorded sessions. - importWorkouts(): query HKWorkouts over a bounded lookback, create a finished ActivitySession for each, pulling calories/distance/HR via the non-deprecated statistics(for:) API. - Skip workouts this app wrote to Health (own HK source) to avoid a loop. - Dedupe re-imports by HKWorkout.uuid, stored on a new optional ActivitySession.healthKitWorkoutID (additive → lightweight migration). - Map HKWorkoutActivityType onto PulseLoop's canonical activity types. - Exclude imported rows from the export path and daily-total netting (incl. forced re-sync) so they're never written back or double-counted. ActivityView already @Querys finished sessions, so imports surface with no UI change. Adds a unit test for the type mapping (HKHealthStore can't be exercised in unit tests). Co-Authored-By: Claude Opus 4.8 --- PulseLoop/Health/HealthSyncService.swift | 171 +++++++++++++++++++++- PulseLoop/Models/PulseModels.swift | 6 + PulseLoopTests/ActivityServiceTests.swift | 24 +++ 3 files changed, 194 insertions(+), 7 deletions(-) diff --git a/PulseLoop/Health/HealthSyncService.swift b/PulseLoop/Health/HealthSyncService.swift index def93ae..3ee926e 100644 --- a/PulseLoop/Health/HealthSyncService.swift +++ b/PulseLoop/Health/HealthSyncService.swift @@ -14,6 +14,11 @@ import os /// 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 { @@ -170,9 +175,12 @@ final class HealthSyncService { } // 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). + // 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 } + .filter { $0.status == .finished && $0.endedAt != nil && $0.healthKitWorkoutID == nil } // Phase 1 — incremental sync of unsynced state. var counts = SyncCounts() @@ -181,10 +189,14 @@ final class HealthSyncService { 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 } + .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)" @@ -648,6 +660,7 @@ final class HealthSyncService { var dailyMetrics = 0 var sleep = 0 var workouts = 0 + var workoutsImported = 0 var summary: String { var parts: [String] = [] @@ -655,8 +668,12 @@ final class HealthSyncService { 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")") } - guard !parts.isEmpty else { return "Nothing to sync yet." } - return "Synced " + parts.joined(separator: ", ") + " to Apple Health." + 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 } } @@ -675,13 +692,153 @@ final class HealthSyncService { s.syncedAt = nil } let workouts = (try? context.fetch(FetchDescriptor())) ?? [] - for w in workouts { + 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 diff --git a/PulseLoop/Models/PulseModels.swift b/PulseLoop/Models/PulseModels.swift index 16f4045..b1edbb4 100644 --- a/PulseLoop/Models/PulseModels.swift +++ b/PulseLoop/Models/PulseModels.swift @@ -484,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, diff --git a/PulseLoopTests/ActivityServiceTests.swift b/PulseLoopTests/ActivityServiceTests.swift index 798ea75..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() From a9c75b451da6488d9e29e7e43bf568a382b62fd4 Mon Sep 17 00:00:00 2001 From: Trent Hoverman Date: Tue, 30 Jun 2026 11:03:15 -0400 Subject: [PATCH 8/8] style: clear pre-existing SwiftLint errors blocking CI These predate the workout-import change but fail the SwiftLint job (serious violations exit non-zero even without --strict): - HealthSettingsSection.swift: wrap two >200-char alert message strings. - RecordViews.swift: inline a single-use local to drop the file one line under the 1000-line file_length limit. No behavior change. Co-Authored-By: Claude Opus 4.8 --- PulseLoop/Health/HealthSettingsSection.swift | 18 ++++++++++++------ PulseLoop/Views/RecordViews.swift | 5 ++--- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/PulseLoop/Health/HealthSettingsSection.swift b/PulseLoop/Health/HealthSettingsSection.swift index 1ddee0c..a133cb0 100644 --- a/PulseLoop/Health/HealthSettingsSection.swift +++ b/PulseLoop/Health/HealthSettingsSection.swift @@ -74,9 +74,12 @@ struct HealthSettingsSection: View { 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) + 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) @@ -90,9 +93,12 @@ struct HealthSettingsSection: View { 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) + 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) } } } diff --git a/PulseLoop/Views/RecordViews.swift b/PulseLoop/Views/RecordViews.swift index 6607aee..16133bf 100644 --- a/PulseLoop/Views/RecordViews.swift +++ b/PulseLoop/Views/RecordViews.swift @@ -1143,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))