diff --git a/targets/watch/ContentView.swift b/targets/watch/ContentView.swift index 00ea613..5fa4c1b 100644 --- a/targets/watch/ContentView.swift +++ b/targets/watch/ContentView.swift @@ -6,7 +6,9 @@ struct ContentView: View { var body: some View { NavigationView { VStack(spacing: 16) { - if workoutManager.isWorkoutActive { + if workoutManager.workoutSummary != nil { + WorkoutSummaryView(workoutManager: workoutManager) + } else if workoutManager.isWorkoutActive { ActiveWorkoutView(workoutManager: workoutManager) } else if workoutManager.selectedWorkout != nil { WorkoutDetailView(workoutManager: workoutManager) diff --git a/targets/watch/Localizable.xcstrings b/targets/watch/Localizable.xcstrings index 7368bfc..c81d1ea 100644 --- a/targets/watch/Localizable.xcstrings +++ b/targets/watch/Localizable.xcstrings @@ -210,6 +210,104 @@ "pt": { "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" } } + } } } } diff --git a/targets/watch/Views/ActiveWorkoutView.swift b/targets/watch/Views/ActiveWorkoutView.swift index c4a38e9..64ae54f 100644 --- a/targets/watch/Views/ActiveWorkoutView.swift +++ b/targets/watch/Views/ActiveWorkoutView.swift @@ -61,7 +61,7 @@ struct SwimView: View { Image(systemName: "flame.fill") .foregroundColor(.orange) .font(.caption) - Text("\(Int(workoutManager.estimatedCalories))") + Text("\(Int(workoutManager.activeCalories))") .font(.caption) } @@ -143,7 +143,7 @@ struct RestView: View { Image(systemName: "flame.fill") .font(.caption) .foregroundColor(.orange) - Text("\(Int(workoutManager.estimatedCalories))kcal") + Text("\(Int(workoutManager.activeCalories))kcal") .font(.caption) } } diff --git a/targets/watch/Views/WorkoutSummaryView.swift b/targets/watch/Views/WorkoutSummaryView.swift new file mode 100644 index 0000000..e270946 --- /dev/null +++ b/targets/watch/Views/WorkoutSummaryView.swift @@ -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) + } +} diff --git a/targets/watch/WorkoutManager.swift b/targets/watch/WorkoutManager.swift index 92db94e..0b993d0 100644 --- a/targets/watch/WorkoutManager.swift +++ b/targets/watch/WorkoutManager.swift @@ -15,8 +15,10 @@ class WorkoutManager: ObservableObject { @Published var currentLapDistance = 0 @Published var elapsedSeconds: TimeInterval = 0 @Published var heartRate: Double = 0 + @Published var activeCalories: Double = 0 @Published var isResting = false @Published var restTimeRemaining: TimeInterval = 0 + @Published var workoutSummary: WorkoutSummary? private var workoutTimer: Timer? private var restTimer: Timer? @@ -26,6 +28,10 @@ class WorkoutManager: ObservableObject { private var lapStartTime: Date? private var completedLaps: [CompletedLap] = [] + // Heart rate & calorie queries + private var heartRateQuery: HKObserverQuery? + private var calorieQuery: HKObserverQuery? + // Lap detection private let motionManager = CMMotionManager() private var lapDetectionTimer: Timer? @@ -91,6 +97,10 @@ class WorkoutManager: ObservableObject { selectedWorkout = workout } + func dismissSummary() { + workoutSummary = nil + } + // MARK: - Workout Control func startWorkout() { @@ -100,11 +110,15 @@ class WorkoutManager: ObservableObject { currentSetIndex = 0 currentRep = 0 elapsedSeconds = 0 + activeCalories = 0 + heartRate = 0 completedLaps = [] wasStationary = false stationarySince = nil startHealthKitWorkout() + startHeartRateQuery() + startCalorieQuery() startTimer() startLapDetection() WKInterfaceDevice.current().enableWaterLock() @@ -114,12 +128,16 @@ class WorkoutManager: ObservableObject { func pauseWorkout() { workoutTimer?.invalidate() stopLapDetection() + stopHeartRateQuery() + stopCalorieQuery() WKInterfaceDevice.current().play(.notification) } func resumeWorkout() { startTimer() startLapDetection() + startHeartRateQuery() + startCalorieQuery() WKInterfaceDevice.current().play(.start) } @@ -129,11 +147,27 @@ class WorkoutManager: ObservableObject { isWorkoutActive = false stopLapDetection() + stopHeartRateQuery() + stopCalorieQuery() endHealthKitWorkout() + + createWorkoutSummary() syncCompletedWorkout() 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 func completeLap() { @@ -233,8 +267,87 @@ class WorkoutManager: ObservableObject { return Double(set.distancePerRep) * Double(set.repCount) } - var estimatedCalories: Double { - return totalDistanceSwum * 0.07 + // MARK: - Heart Rate Query + + 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) @@ -349,7 +462,7 @@ class WorkoutManager: ObservableObject { }, "duration": elapsedSeconds, "totalDistance": Int(totalDistanceSwum), - "calories": Int(estimatedCalories), + "calories": Int(activeCalories), ] var completed = completedWorkouts @@ -393,3 +506,10 @@ struct CompletedLap { let duration: TimeInterval let heartRate: Int? } + +struct WorkoutSummary { + let duration: TimeInterval + let totalDistance: Int + let calories: Int + let laps: [CompletedLap] +}