feat(watchos): UI live workout + entry point (Phase 2)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,134 @@
|
||||
import SwiftUI
|
||||
import HealthKit
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject private var workout: WorkoutManager
|
||||
|
||||
var body: some View {
|
||||
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) {
|
||||
Image(systemName: "heart.fill")
|
||||
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)
|
||||
Text("Coach")
|
||||
.font(.headline)
|
||||
Text("Prêt")
|
||||
}
|
||||
}
|
||||
.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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user