feat(watch): add workout summary screen with HealthKit data

This commit is contained in:
2026-08-08 12:05:32 +02:00
parent 831ec6b836
commit f3b3a8b944
5 changed files with 307 additions and 6 deletions
+3 -1
View File
@@ -6,7 +6,9 @@ struct ContentView: View {
var body: some View { var body: some View {
NavigationView { NavigationView {
VStack(spacing: 16) { VStack(spacing: 16) {
if workoutManager.isWorkoutActive { if workoutManager.workoutSummary != nil {
WorkoutSummaryView(workoutManager: workoutManager)
} else if workoutManager.isWorkoutActive {
ActiveWorkoutView(workoutManager: workoutManager) ActiveWorkoutView(workoutManager: workoutManager)
} else if workoutManager.selectedWorkout != nil { } else if workoutManager.selectedWorkout != nil {
WorkoutDetailView(workoutManager: workoutManager) WorkoutDetailView(workoutManager: workoutManager)
+98
View File
@@ -210,6 +210,104 @@
"pt": { "stringUnit": { "state": "translated", "value": "%@kcal" } }, "pt": { "stringUnit": { "state": "translated", "value": "%@kcal" } },
"dk": { "stringUnit": { "state": "translated", "value": "%@kcal" } } "dk": { "stringUnit": { "state": "translated", "value": "%@kcal" } }
} }
},
"Workout complete!": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Workout complete!" } },
"nl": { "stringUnit": { "state": "translated", "value": "Training voltooid!" } },
"de": { "stringUnit": { "state": "translated", "value": "Training abgeschlossen!" } },
"fr": { "stringUnit": { "state": "translated", "value": "Entraînement terminé !" } },
"it": { "stringUnit": { "state": "translated", "value": "Allenamento completato!" } },
"es": { "stringUnit": { "state": "translated", "value": "¡Entrenamiento completado!" } },
"pl": { "stringUnit": { "state": "translated", "value": "Trening zakończony!" } },
"pt": { "stringUnit": { "state": "translated", "value": "Treino concluído!" } },
"dk": { "stringUnit": { "state": "translated", "value": "Træning afsluttet!" } }
}
},
"Stats": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Stats" } },
"nl": { "stringUnit": { "state": "translated", "value": "Statistieken" } },
"de": { "stringUnit": { "state": "translated", "value": "Statistiken" } },
"fr": { "stringUnit": { "state": "translated", "value": "Statistiques" } },
"it": { "stringUnit": { "state": "translated", "value": "Statistiche" } },
"es": { "stringUnit": { "state": "translated", "value": "Estadísticas" } },
"pl": { "stringUnit": { "state": "translated", "value": "Statystyki" } },
"pt": { "stringUnit": { "state": "translated", "value": "Estatísticas" } },
"dk": { "stringUnit": { "state": "translated", "value": "Statistik" } }
}
},
"Total time": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Total time" } },
"nl": { "stringUnit": { "state": "translated", "value": "Totale tijd" } },
"de": { "stringUnit": { "state": "translated", "value": "Gesamtzeit" } },
"fr": { "stringUnit": { "state": "translated", "value": "Durée totale" } },
"it": { "stringUnit": { "state": "translated", "value": "Tempo totale" } },
"es": { "stringUnit": { "state": "translated", "value": "Tiempo total" } },
"pl": { "stringUnit": { "state": "translated", "value": "Czas łączny" } },
"pt": { "stringUnit": { "state": "translated", "value": "Tempo total" } },
"dk": { "stringUnit": { "state": "translated", "value": "Samlet tid" } }
}
},
"Distance": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Distance" } },
"nl": { "stringUnit": { "state": "translated", "value": "Afstand" } },
"de": { "stringUnit": { "state": "translated", "value": "Distanz" } },
"fr": { "stringUnit": { "state": "translated", "value": "Distance" } },
"it": { "stringUnit": { "state": "translated", "value": "Distanza" } },
"es": { "stringUnit": { "state": "translated", "value": "Distancia" } },
"pl": { "stringUnit": { "state": "translated", "value": "Dystans" } },
"pt": { "stringUnit": { "state": "translated", "value": "Distância" } },
"dk": { "stringUnit": { "state": "translated", "value": "Distance" } }
}
},
"Calories": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Calories" } },
"nl": { "stringUnit": { "state": "translated", "value": "Calorieën" } },
"de": { "stringUnit": { "state": "translated", "value": "Kalorien" } },
"fr": { "stringUnit": { "state": "translated", "value": "Calories" } },
"it": { "stringUnit": { "state": "translated", "value": "Calorie" } },
"es": { "stringUnit": { "state": "translated", "value": "Calorías" } },
"pl": { "stringUnit": { "state": "translated", "value": "Kalorie" } },
"pt": { "stringUnit": { "state": "translated", "value": "Calorias" } },
"dk": { "stringUnit": { "state": "translated", "value": "Kalorier" } }
}
},
"Laps": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Laps" } },
"nl": { "stringUnit": { "state": "translated", "value": "Rondes" } },
"de": { "stringUnit": { "state": "translated", "value": "Runden" } },
"fr": { "stringUnit": { "state": "translated", "value": "Longueurs" } },
"it": { "stringUnit": { "state": "translated", "value": "Lunghezze" } },
"es": { "stringUnit": { "state": "translated", "value": "Largos" } },
"pl": { "stringUnit": { "state": "translated", "value": "Długości" } },
"pt": { "stringUnit": { "state": "translated", "value": "Comprimentos" } },
"dk": { "stringUnit": { "state": "translated", "value": "Baner" } }
}
},
"OK": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "OK" } },
"nl": { "stringUnit": { "state": "translated", "value": "OK" } },
"de": { "stringUnit": { "state": "translated", "value": "OK" } },
"fr": { "stringUnit": { "state": "translated", "value": "OK" } },
"it": { "stringUnit": { "state": "translated", "value": "OK" } },
"es": { "stringUnit": { "state": "translated", "value": "OK" } },
"pl": { "stringUnit": { "state": "translated", "value": "OK" } },
"pt": { "stringUnit": { "state": "translated", "value": "OK" } },
"dk": { "stringUnit": { "state": "translated", "value": "OK" } }
}
} }
} }
} }
+2 -2
View File
@@ -61,7 +61,7 @@ struct SwimView: View {
Image(systemName: "flame.fill") Image(systemName: "flame.fill")
.foregroundColor(.orange) .foregroundColor(.orange)
.font(.caption) .font(.caption)
Text("\(Int(workoutManager.estimatedCalories))") Text("\(Int(workoutManager.activeCalories))")
.font(.caption) .font(.caption)
} }
@@ -143,7 +143,7 @@ struct RestView: View {
Image(systemName: "flame.fill") Image(systemName: "flame.fill")
.font(.caption) .font(.caption)
.foregroundColor(.orange) .foregroundColor(.orange)
Text("\(Int(workoutManager.estimatedCalories))kcal") Text("\(Int(workoutManager.activeCalories))kcal")
.font(.caption) .font(.caption)
} }
} }
@@ -0,0 +1,81 @@
import SwiftUI
struct WorkoutSummaryView: View {
@ObservedObject var workoutManager: WorkoutManager
var body: some View {
if let summary = workoutManager.workoutSummary {
List {
Section {
VStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 40))
.foregroundColor(.green)
Text("Workout complete!")
.font(.headline)
.foregroundColor(.green)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
Section("Stats") {
HStack {
Text("Total time")
.foregroundColor(.secondary)
Spacer()
Text(formatTime(summary.duration))
.monospacedDigit()
}
HStack {
Text("Distance")
.foregroundColor(.secondary)
Spacer()
Text("\(summary.totalDistance)m")
.monospacedDigit()
}
HStack {
Text("Calories")
.foregroundColor(.secondary)
Spacer()
Text("\(summary.calories)kcal")
.monospacedDigit()
}
HStack {
Text("Laps")
.foregroundColor(.secondary)
Spacer()
Text("\(summary.laps.count)")
.monospacedDigit()
}
}
Section {
Button {
workoutManager.dismissSummary()
} label: {
HStack {
Spacer()
Text("OK")
.font(.headline)
Spacer()
}
}
.buttonStyle(.borderedProminent)
.tint(.cyan)
}
}
.listStyle(.automatic)
}
}
private func formatTime(_ seconds: TimeInterval) -> String {
let mins = Int(seconds) / 60
let secs = Int(seconds) % 60
return String(format: "%d:%02d", mins, secs)
}
}
+123 -3
View File
@@ -15,8 +15,10 @@ class WorkoutManager: ObservableObject {
@Published var currentLapDistance = 0 @Published var currentLapDistance = 0
@Published var elapsedSeconds: TimeInterval = 0 @Published var elapsedSeconds: TimeInterval = 0
@Published var heartRate: Double = 0 @Published var heartRate: Double = 0
@Published var activeCalories: Double = 0
@Published var isResting = false @Published var isResting = false
@Published var restTimeRemaining: TimeInterval = 0 @Published var restTimeRemaining: TimeInterval = 0
@Published var workoutSummary: WorkoutSummary?
private var workoutTimer: Timer? private var workoutTimer: Timer?
private var restTimer: Timer? private var restTimer: Timer?
@@ -26,6 +28,10 @@ class WorkoutManager: ObservableObject {
private var lapStartTime: Date? private var lapStartTime: Date?
private var completedLaps: [CompletedLap] = [] private var completedLaps: [CompletedLap] = []
// Heart rate & calorie queries
private var heartRateQuery: HKObserverQuery?
private var calorieQuery: HKObserverQuery?
// Lap detection // Lap detection
private let motionManager = CMMotionManager() private let motionManager = CMMotionManager()
private var lapDetectionTimer: Timer? private var lapDetectionTimer: Timer?
@@ -91,6 +97,10 @@ class WorkoutManager: ObservableObject {
selectedWorkout = workout selectedWorkout = workout
} }
func dismissSummary() {
workoutSummary = nil
}
// MARK: - Workout Control // MARK: - Workout Control
func startWorkout() { func startWorkout() {
@@ -100,11 +110,15 @@ class WorkoutManager: ObservableObject {
currentSetIndex = 0 currentSetIndex = 0
currentRep = 0 currentRep = 0
elapsedSeconds = 0 elapsedSeconds = 0
activeCalories = 0
heartRate = 0
completedLaps = [] completedLaps = []
wasStationary = false wasStationary = false
stationarySince = nil stationarySince = nil
startHealthKitWorkout() startHealthKitWorkout()
startHeartRateQuery()
startCalorieQuery()
startTimer() startTimer()
startLapDetection() startLapDetection()
WKInterfaceDevice.current().enableWaterLock() WKInterfaceDevice.current().enableWaterLock()
@@ -114,12 +128,16 @@ class WorkoutManager: ObservableObject {
func pauseWorkout() { func pauseWorkout() {
workoutTimer?.invalidate() workoutTimer?.invalidate()
stopLapDetection() stopLapDetection()
stopHeartRateQuery()
stopCalorieQuery()
WKInterfaceDevice.current().play(.notification) WKInterfaceDevice.current().play(.notification)
} }
func resumeWorkout() { func resumeWorkout() {
startTimer() startTimer()
startLapDetection() startLapDetection()
startHeartRateQuery()
startCalorieQuery()
WKInterfaceDevice.current().play(.start) WKInterfaceDevice.current().play(.start)
} }
@@ -129,11 +147,27 @@ class WorkoutManager: ObservableObject {
isWorkoutActive = false isWorkoutActive = false
stopLapDetection() stopLapDetection()
stopHeartRateQuery()
stopCalorieQuery()
endHealthKitWorkout() endHealthKitWorkout()
createWorkoutSummary()
syncCompletedWorkout() syncCompletedWorkout()
WKInterfaceDevice.current().play(.success) WKInterfaceDevice.current().play(.success)
} }
// MARK: - Workout Summary
private func createWorkoutSummary() {
let summary = WorkoutSummary(
duration: elapsedSeconds,
totalDistance: Int(totalDistanceSwum),
calories: Int(activeCalories),
laps: completedLaps
)
workoutSummary = summary
}
// MARK: - Lap Tracking // MARK: - Lap Tracking
func completeLap() { func completeLap() {
@@ -233,8 +267,87 @@ class WorkoutManager: ObservableObject {
return Double(set.distancePerRep) * Double(set.repCount) return Double(set.distancePerRep) * Double(set.repCount)
} }
var estimatedCalories: Double { // MARK: - Heart Rate Query
return totalDistanceSwum * 0.07
private func startHeartRateQuery() {
guard let healthStore = healthStore else { return }
let heartRateType = HKQuantityType.quantityType(forIdentifier: .heartRate)!
heartRateQuery = HKObserverQuery(sampleType: heartRateType, predicate: nil) { [weak self] _, completionHandler, _ in
self?.fetchLatestHeartRate()
completionHandler()
}
if let query = heartRateQuery {
healthStore.execute(query)
healthStore.enableBackgroundDelivery(for: heartRateType, frequency: .immediate) { _, _, _ in }
}
}
private func stopHeartRateQuery() {
if let query = heartRateQuery {
healthStore?.stop(query)
heartRateQuery = nil
}
}
private func fetchLatestHeartRate() {
guard let healthStore = healthStore else { return }
let heartRateType = HKQuantityType.quantityType(forIdentifier: .heartRate)!
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
let query = HKSampleQuery(sampleType: heartRateType, predicate: nil, limit: 1, sortDescriptors: [sortDescriptor]) { [weak self] _, samples, _ in
guard let sample = samples?.first as? HKQuantitySample else { return }
let heartRateUnit = HKUnit.count().unitDivided(by: .minute())
let value = sample.quantity.doubleValue(for: heartRateUnit)
DispatchQueue.main.async {
self?.heartRate = value
}
}
healthStore.execute(query)
}
// MARK: - Calorie Query
private func startCalorieQuery() {
guard let healthStore = healthStore else { return }
let calorieType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!
calorieQuery = HKObserverQuery(sampleType: calorieType, predicate: nil) { [weak self] _, completionHandler, _ in
self?.fetchActiveCalories()
completionHandler()
}
if let query = calorieQuery {
healthStore.execute(query)
healthStore.enableBackgroundDelivery(for: calorieType, frequency: .immediate) { _, _, _ in }
}
}
private func stopCalorieQuery() {
if let query = calorieQuery {
healthStore?.stop(query)
calorieQuery = nil
}
}
private func fetchActiveCalories() {
guard let healthStore = healthStore else { return }
let calorieType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!
let now = Date()
let predicate = HKQuery.predicateForSamples(withStart: now.addingTimeInterval(-elapsedSeconds), end: now, options: .strictStartDate)
let query = HKStatisticsQuery(quantityType: calorieType, quantitySamplePredicate: predicate, options: .cumulativeSum) { [weak self] _, result, _ in
guard let sum = result?.sumQuantity() else { return }
let calories = sum.doubleValue(for: HKUnit.kilocalorie())
DispatchQueue.main.async {
self?.activeCalories = calories
}
}
healthStore.execute(query)
} }
// MARK: - Lap Detection (Accelerometer) // MARK: - Lap Detection (Accelerometer)
@@ -349,7 +462,7 @@ class WorkoutManager: ObservableObject {
}, },
"duration": elapsedSeconds, "duration": elapsedSeconds,
"totalDistance": Int(totalDistanceSwum), "totalDistance": Int(totalDistanceSwum),
"calories": Int(estimatedCalories), "calories": Int(activeCalories),
] ]
var completed = completedWorkouts var completed = completedWorkouts
@@ -393,3 +506,10 @@ struct CompletedLap {
let duration: TimeInterval let duration: TimeInterval
let heartRate: Int? let heartRate: Int?
} }
struct WorkoutSummary {
let duration: TimeInterval
let totalDistance: Int
let calories: Int
let laps: [CompletedLap]
}