Pendant natif du lecteur web. Bouton Démarrer par bloc : un exercice à la fois, décompte en anneau, passage auto, haptique à chaque transition, pause et navigation. Pendant une routine au sol, la montre est bien plus pratique que d'aller chercher le téléphone — c'est tout l'intérêt de la version watchOS. Chaque exercice écoulé est coché via RoutineStore.toggle, donc renvoyé à l'iPhone puis au serveur : à la fin le bloc est à jour sur les trois surfaces. La durée par exercice est désormais transmise dans le snapshot (duration_sec, optionnel pour rester compatible avec les snapshots déjà persistés) : la vue guidée utilise la vraie durée au lieu de répartir celle du bloc. RoutineGuidedView.swift est déjà référencé dans le pbxproj (target CoachWatch), aucun Add Files à faire. Backup : project.pbxproj.bak-guided NON COMPILÉ — à inclure au prochain build Xcode. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
157 lines
5.4 KiB
Swift
157 lines
5.4 KiB
Swift
import SwiftUI
|
|
import WatchKit
|
|
|
|
/// Routine quotidienne sur la montre : les exercices, cochables un par un au fil
|
|
/// de la séance. Chaque coche part vers l'iPhone, qui la POSTe au serveur —
|
|
/// l'état est donc partagé avec la page /routine et le widget de la home.
|
|
struct RoutineView: View {
|
|
@StateObject private var store = RoutineStore.shared
|
|
@State private var guided: RoutineBlock?
|
|
|
|
var body: some View {
|
|
Group {
|
|
if store.isEmpty {
|
|
emptyState
|
|
} else {
|
|
list
|
|
}
|
|
}
|
|
.navigationTitle("Routine")
|
|
.sheet(item: $guided) { block in
|
|
RoutineGuidedView(block: block)
|
|
}
|
|
}
|
|
|
|
private var emptyState: some View {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "figure.cooldown")
|
|
.font(.title2)
|
|
.foregroundStyle(.secondary)
|
|
Text("Ouvre l'app Coach sur l'iPhone une fois pour recevoir la routine.")
|
|
.font(.footnote)
|
|
.multilineTextAlignment(.center)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding()
|
|
}
|
|
|
|
private var list: some View {
|
|
ScrollView {
|
|
LazyVStack(spacing: 6, pinnedViews: []) {
|
|
progressHeader
|
|
|
|
ForEach(store.morningBlocks) { block in
|
|
blockSection(block)
|
|
}
|
|
|
|
if !store.otherBlocks.isEmpty {
|
|
Text("APRÈS LA COURSE")
|
|
.font(.caption2)
|
|
.foregroundStyle(.orange)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.top, 8)
|
|
ForEach(store.otherBlocks) { block in
|
|
blockSection(block)
|
|
}
|
|
}
|
|
|
|
if store.lastSyncFailed {
|
|
Button {
|
|
store.retry()
|
|
} label: {
|
|
Label("Renvoyer vers l'iPhone", systemImage: "arrow.clockwise")
|
|
.font(.footnote)
|
|
}
|
|
.tint(.orange)
|
|
.padding(.top, 4)
|
|
}
|
|
}
|
|
.padding(.horizontal, 4)
|
|
}
|
|
}
|
|
|
|
private var progressHeader: some View {
|
|
let total = store.totalExercises
|
|
let count = store.done.count
|
|
return VStack(spacing: 4) {
|
|
Text("\(count) / \(total) exercices")
|
|
.font(.headline)
|
|
.foregroundStyle(count == total && total > 0 ? .green : .primary)
|
|
ProgressView(value: total > 0 ? Double(count) / Double(total) : 0)
|
|
.tint(.cyan)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
/// En-tête de bloc (tap = tout cocher d'un coup), bouton de séance guidée,
|
|
/// puis ses exercices.
|
|
private func blockSection(_ block: RoutineBlock) -> some View {
|
|
let complete = store.isComplete(block)
|
|
return VStack(spacing: 4) {
|
|
Button {
|
|
store.toggleBlock(block)
|
|
WKInterfaceDevice.current().play(complete ? .click : .success)
|
|
} label: {
|
|
HStack(spacing: 6) {
|
|
Text("\(block.num). \(block.title)")
|
|
.font(.caption)
|
|
.fontWeight(.semibold)
|
|
.multilineTextAlignment(.leading)
|
|
Spacer(minLength: 0)
|
|
Text("\(store.doneCount(in: block))/\(block.items.count)")
|
|
.font(.caption2)
|
|
.monospacedDigit()
|
|
.foregroundStyle(complete ? .green : .secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.padding(.top, 6)
|
|
|
|
Button {
|
|
guided = block
|
|
} label: {
|
|
Label("Démarrer", systemImage: "play.fill")
|
|
.font(.caption2)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.tint(.cyan)
|
|
|
|
ForEach(block.items) { item in
|
|
exerciseRow(item)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func exerciseRow(_ item: RoutineExercise) -> some View {
|
|
let isDone = store.done.contains(item.id)
|
|
return Button {
|
|
store.toggle(item.id)
|
|
WKInterfaceDevice.current().play(isDone ? .click : .success)
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: isDone ? "checkmark.circle.fill" : "circle")
|
|
.foregroundStyle(isDone ? .green : .secondary)
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(item.name)
|
|
.font(.footnote)
|
|
.multilineTextAlignment(.leading)
|
|
.strikethrough(isDone, color: .secondary)
|
|
if !item.dose.isEmpty {
|
|
Text(item.dose)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.padding(.vertical, 6)
|
|
.padding(.horizontal, 8)
|
|
.background(Color.gray.opacity(isDone ? 0.10 : 0.18), in: RoundedRectangle(cornerRadius: 8))
|
|
.opacity(isDone ? 0.65 : 1)
|
|
}
|
|
}
|