Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions PulseLoop/App/AppTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ enum AppRoute: Hashable {
case settingsGoals
case settingsVitals
case settingsPrivacyData
case settingsHealth
case settingsAbout
case pairing
case debug
Expand Down
2 changes: 2 additions & 0 deletions PulseLoop/Coach/Orchestration/PendingActionExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
Expand Down
4 changes: 3 additions & 1 deletion PulseLoop/Coach/Tools/ActionTools.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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])
}
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion PulseLoop/DesignSystem/Components.swift
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,7 @@ enum ActivityMeta {

struct ActivityWorkoutRow: View {
let session: ActivitySession
var units: UnitsPreference = .metric
var onTap: (() -> Void)?

var body: some View {
Expand All @@ -626,7 +627,8 @@ struct ActivityWorkoutRow: View {
HStack(spacing: 12) {
Text(durationLabel).font(.system(size: 12).monospacedDigit())
if let distance = session.distanceMeters {
Text(String(format: "%.2f km", distance / 1000)).font(.system(size: 12).monospacedDigit())
let d = UnitsFormatter.distance(meters: distance, units: units)
Text("\(d.value) \(d.unit)").font(.system(size: 12).monospacedDigit())
}
if let hr = session.avgHeartRate {
Text("\(Int(hr)) bpm avg").font(.system(size: 12).monospacedDigit())
Expand Down
34 changes: 29 additions & 5 deletions PulseLoop/Events/PulseEventBus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ final class EventPersistenceSubscriber {
calories: calories,
distanceMeters: distanceMeters,
source: "live",
syncedAt: Date()
syncedAt: nil
),
context: context
)
Expand All @@ -154,28 +154,36 @@ final class EventPersistenceSubscriber {
entityId: row.id.uuidString,
payloadJSON: #"{"steps":\#(row.steps),"calories":\#(Int(row.calories)),"distance_m":\#(Int(row.distanceMeters))}"#
))
HealthSyncService.shared.triggerAutomaticSync(context: context)
case let .activityBucket(timestamp, steps, distanceMeters):
// Per-quarter-hour ring history: upserted by timestamp + the day total recomputed as the
// sum of distinct buckets, so re-syncs are idempotent (no drift). Calories omitted.
ActivityService.applyActivityBucket(date: timestamp, steps: steps, distanceMeters: distanceMeters, context: context)
HealthSyncService.shared.triggerAutomaticSync(context: context)
case .activitySyncReset:
// No longer needed — bucket upsert-by-timestamp makes re-syncs idempotent on its own.
// Kept as a no-op so the (still-published) event doesn't fall through to `unknown`.
break
case let .heartRateSample(bpm, timestamp):
persistMeasurement(kind: .heartRate, value: Double(bpm), timestamp: timestamp, source: .live, kindLabel: "hr_sample")
HealthSyncService.shared.triggerAutomaticSync(context: context)
case let .spo2Result(value, timestamp):
persistMeasurement(kind: .spo2, value: Double(value), timestamp: timestamp, source: .live, kindLabel: "spo2_result")
HealthSyncService.shared.triggerAutomaticSync(context: context)
case let .historyMeasurement(kind, value, timestamp):
persistMeasurement(kind: kind, value: value, timestamp: timestamp, source: .history, kindLabel: "history_measurement")
HealthSyncService.shared.triggerAutomaticSync(context: context)
case let .stressSample(value, timestamp):
persistMeasurement(kind: .stress, value: Double(value), timestamp: timestamp, source: .colmi, kindLabel: "stress_sample")
case let .hrvSample(value, timestamp):
persistMeasurement(kind: .hrv, value: Double(value), timestamp: timestamp, source: .colmi, kindLabel: "hrv_sample")
HealthSyncService.shared.triggerAutomaticSync(context: context)
case let .temperatureSample(celsius, timestamp):
persistMeasurement(kind: .temperature, value: celsius, timestamp: timestamp, source: .colmi, kindLabel: "temperature_sample")
HealthSyncService.shared.triggerAutomaticSync(context: context)
case let .sleepTimeline(timestamp, stages):
persistSleepTimeline(start: timestamp, stages: stages)
HealthSyncService.shared.triggerAutomaticSync(context: context)
case let .gpsPoint(sessionId, latitude, longitude, altitude, horizontalAccuracy, speed, course, accepted, rejectionReason, timestamp):
context.insert(ActivityGpsPoint(
sessionId: sessionId,
Expand Down Expand Up @@ -203,11 +211,26 @@ final class EventPersistenceSubscriber {
try? context.save()
}

/// Persist one live/history measurement, record a derived-update audit row, and link it to
/// an in-progress workout if one is recording. Mirrors `persistence._on_hr_sample`.
private func persistMeasurement(kind: MeasurementKind, value: Double, timestamp: Date, source: MeasurementSource, kindLabel: String) {
let row = Measurement(kind: kind, value: value, unit: kind.unit, timestamp: timestamp, source: source)
context.insert(row)
let kindRaw = kind.rawValue
let descriptor = FetchDescriptor<Measurement>(
predicate: #Predicate<Measurement> { $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,
Expand All @@ -220,6 +243,7 @@ final class EventPersistenceSubscriber {
)
}


/// Upsert a sleep session by night, appending this packet's per-minute stage blocks and
/// recomputing session bounds. The ring streams ~20 timeline packets (15 samples each) per
/// night, so blocks must accumulate into one session rather than spawning a session per
Expand Down
16 changes: 16 additions & 0 deletions PulseLoop/Health/HealthSettingsDetailView.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
131 changes: 131 additions & 0 deletions PulseLoop/Health/HealthSettingsSection.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import SwiftUI
import UIKit

/// "Apple Health" block for `SettingsView`, shown above the AI Coach section.
/// Tapping **Apple Health** checks the connection: if already authorized it confirms
/// so, otherwise it presents the system Health Access sheet (read + write for every
/// metric the ring captures). **Sync workouts history** exports everything captured.
struct HealthSettingsSection: View {
@Environment(\.modelContext) private var modelContext
@State private var service = HealthSyncService.shared
@State private var alert: HealthAlert?
@State private var statusText = "—"

var body: some View {
Group {
SectionHeader(title: "Apple Health", action: nil)
StatusCopy(title: "Status", body: statusText)

SecondaryButton(title: "Apple Health", systemImage: "heart.fill") {
handleAppleHealthTap()
}

SecondaryButton(
title: service.isSyncing ? "Syncing…" : "Sync workouts history",
systemImage: "arrow.triangle.2.circlepath"
) {
syncHistory()
}
.disabled(service.isSyncing)

if let result = service.lastResult {
Text(result)
.font(.caption)
.foregroundStyle(PulseColors.textMuted)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.onAppear(perform: refreshStatus)
.alert(
alert?.title ?? "",
isPresented: Binding(get: { alert != nil }, set: { if !$0 { alert = nil } }),
presenting: alert
) { a in
if a.showSettings {
Button("Open Settings") { openSettings() }
}
Button("OK", role: .cancel) {}
} message: { a in
Text(a.message)
}
}

// MARK: - Actions

private func handleAppleHealthTap() {
switch service.authState {
case .unavailable:
alert = HealthAlert(title: "Apple Health Unavailable",
message: "Apple Health isn't available on this device.")
case .authorized:
alert = HealthAlert(title: "Apple Health Connected",
message: "PulseLoop is connected to Apple Health and can read and write your ring's data. Use “Sync workouts history” to push everything captured.")
case .denied:
alert = HealthAlert(title: "Apple Health Access Off",
message: "Allow PulseLoop to read and write health data in the Health app (under Sharing → Apps & Services) or in system Settings → Health → Data Access & Devices.",
showSettings: true)
case .notDetermined:
Task {
do {
try await service.requestAuthorization()
refreshStatus()
switch service.authState {
case .authorized:
alert = HealthAlert(title: "Apple Health Connected",
message: "PulseLoop can now sync your ring data to Apple Health. Tap “Sync workouts history” to export everything captured.")
default:
alert = HealthAlert(
title: "Apple Health",
message: "You can change PulseLoop's access anytime in the Health app "
+ "(under Sharing → Apps & Services) or in system Settings → "
+ "Health → Data Access & Devices.",
showSettings: true)
}
} catch {
alert = HealthAlert(title: "Apple Health", message: error.localizedDescription)
}
}
}
}

private func syncHistory() {
Task {
await service.syncAll(context: modelContext, forceAll: true)
refreshStatus()
if service.authState == .denied {
alert = HealthAlert(
title: "Apple Health Access Off",
message: "Allow PulseLoop to read and write health data in the Health app "
+ "(under Sharing → Apps & Services) or in system Settings → "
+ "Health → Data Access & Devices.",
showSettings: true)
}
}
}

private func refreshStatus() {
switch service.authState {
case .unavailable: statusText = "Not available on this device"
case .authorized: statusText = "Connected — reading & writing ring data"
case .denied: statusText = "Access off (enable in Settings)"
case .notDetermined: statusText = "Not connected"
}
}

private func openSettings() {
if let healthURL = URL(string: "x-apple-health://") {
UIApplication.shared.open(healthURL) { success in
if !success, let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
}
}
}

private struct HealthAlert: Identifiable {
let id = UUID()
let title: String
let message: String
var showSettings = false
}
Loading
Loading