feat(watch): add workout summary screen with HealthKit data
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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" } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user