From 518529891b173122bd558f0896b14df67b1bd508 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Sun, 24 May 2026 16:23:06 +0200 Subject: [PATCH] feat(watchos): UI live workout + entry point (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CoachWatchApp: WKApplicationDelegateAdaptor active WCSession au boot, injecte les managers. ContentView: picker 4 activités (course/vélo/marche/renfo) -> vue live (FC/calories/distance/durée + état liaison iPhone) -> bouton Terminer. Build OK. Co-Authored-By: Claude Opus 4.7 (1M context) --- ios/App/CoachWatch/CoachWatchApp.swift | 12 +++ ios/App/CoachWatch/ContentView.swift | 132 +++++++++++++++++++++++-- 2 files changed, 135 insertions(+), 9 deletions(-) diff --git a/ios/App/CoachWatch/CoachWatchApp.swift b/ios/App/CoachWatch/CoachWatchApp.swift index 21cfacd..04352a1 100644 --- a/ios/App/CoachWatch/CoachWatchApp.swift +++ b/ios/App/CoachWatch/CoachWatchApp.swift @@ -1,10 +1,22 @@ import SwiftUI +import WatchKit @main struct CoachWatchApp: App { + @WKApplicationDelegateAdaptor private var appDelegate: WatchAppDelegate + var body: some Scene { WindowGroup { ContentView() + .environmentObject(WorkoutManager.shared) + .environmentObject(ConnectivityManager.shared) } } } + +final class WatchAppDelegate: NSObject, WKApplicationDelegate { + func applicationDidFinishLaunching() { + // Active WCSession dès le boot pour ne rater aucun early message. + _ = ConnectivityManager.shared + } +} diff --git a/ios/App/CoachWatch/ContentView.swift b/ios/App/CoachWatch/ContentView.swift index a493fec..d0a1d5a 100644 --- a/ios/App/CoachWatch/ContentView.swift +++ b/ios/App/CoachWatch/ContentView.swift @@ -1,20 +1,134 @@ import SwiftUI +import HealthKit struct ContentView: View { + @EnvironmentObject private var workout: WorkoutManager + var body: some View { - VStack(spacing: 8) { - Image(systemName: "heart.fill") - .foregroundStyle(.red) - Text("Coach") - .font(.headline) - Text("Prêt") - .font(.caption) - .foregroundStyle(.secondary) + if workout.isRunning { + LiveWorkoutView() + } else { + ActivityPickerView() + } + } +} + +struct ActivityPickerView: View { + @EnvironmentObject private var workout: WorkoutManager + + private struct Activity: Identifiable { + let id = UUID() + let label: String + let symbol: String + let type: HKWorkoutActivityType + let location: HKWorkoutSessionLocationType + } + + private let activities: [Activity] = [ + .init(label: "Course", symbol: "figure.run", type: .running, location: .outdoor), + .init(label: "Vélo", symbol: "figure.outdoor.cycle", type: .cycling, location: .outdoor), + .init(label: "Marche", symbol: "figure.walk", type: .walking, location: .outdoor), + .init(label: "Renfo", symbol: "dumbbell", type: .traditionalStrengthTraining, location: .indoor) + ] + + var body: some View { + ScrollView { + VStack(spacing: 8) { + ForEach(activities) { activity in + Button { + Task { + await workout.startWorkout(activityType: activity.type, + locationType: activity.location) + } + } label: { + Label(activity.label, systemImage: activity.symbol) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + if let message = workout.statusMessage { + Text(message) + .font(.footnote) + .foregroundStyle(.red) + } + } + .padding(.horizontal, 4) + } + .navigationTitle("Coach") + } +} + +struct LiveWorkoutView: View { + @EnvironmentObject private var workout: WorkoutManager + @EnvironmentObject private var connectivity: ConnectivityManager + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 10) { + Metric(label: "FC", value: "\(Int(workout.heartRate))", unit: "bpm", color: .red) + Metric(label: "Calories", value: "\(Int(workout.activeEnergyKcal))", unit: "kcal", color: .orange) + Metric(label: "Distance", + value: String(format: "%.2f", workout.distanceMeters / 1000), + unit: "km", color: .blue) + Metric(label: "Durée", value: Self.timeString(workout.elapsedSec), unit: "", color: .green) + + HStack(spacing: 4) { + Image(systemName: connectivity.isReachable + ? "iphone.radiowaves.left.and.right" : "iphone.slash") + .foregroundStyle(connectivity.isReachable ? .green : .secondary) + Text(connectivity.isReachable ? "iPhone connecté" : "iPhone hors de portée") + .font(.caption2) + .foregroundStyle(.secondary) + } + + Button(role: .destructive) { + workout.end() + } label: { + Label("Terminer", systemImage: "stop.fill") + .frame(maxWidth: .infinity) + } + } + .padding(.horizontal, 4) + } + } + + private static func timeString(_ seconds: Double) -> String { + let total = Int(seconds) + let h = total / 3600 + let m = (total % 3600) / 60 + let s = total % 60 + return h > 0 + ? String(format: "%d:%02d:%02d", h, m, s) + : String(format: "%02d:%02d", m, s) + } +} + +private struct Metric: View { + let label: String + let value: String + let unit: String + let color: Color + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + HStack(alignment: .firstTextBaseline, spacing: 2) { + Text(value) + .font(.system(size: 30, weight: .semibold, design: .rounded)) + .foregroundStyle(color) + if !unit.isEmpty { + Text(unit) + .font(.caption) + .foregroundStyle(.secondary) + } + } } - .padding() } } #Preview { ContentView() + .environmentObject(WorkoutManager.shared) + .environmentObject(ConnectivityManager.shared) }