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>
173 lines
5.6 KiB
Swift
173 lines
5.6 KiB
Swift
import SwiftUI
|
|
import WatchKit
|
|
|
|
/// Séance guidée au poignet : un exercice à la fois, décompte, passage
|
|
/// automatique. Pendant une routine au sol, regarder sa montre est bien plus
|
|
/// pratique que d'aller chercher son téléphone — c'est tout l'intérêt d'avoir
|
|
/// la version watchOS plutôt que le seul lecteur web.
|
|
///
|
|
/// Chaque exercice écoulé est coché via `RoutineStore.toggle`, donc renvoyé à
|
|
/// l'iPhone puis au serveur : à la fin, le bloc est déjà à jour partout.
|
|
struct RoutineGuidedView: View {
|
|
let block: RoutineBlock
|
|
|
|
@StateObject private var store = RoutineStore.shared
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@State private var index = 0
|
|
@State private var remaining = 0.0
|
|
@State private var paused = false
|
|
@State private var finished = false
|
|
|
|
// 0,2 s : assez fluide pour l'anneau, assez lent pour ne pas vider la
|
|
// batterie de la montre sur une séance de 14 minutes.
|
|
private let tick = Timer.publish(every: 0.2, on: .main, in: .common).autoconnect()
|
|
|
|
private var current: RoutineExercise? {
|
|
index < block.items.count ? block.items[index] : nil
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if finished || current == nil {
|
|
completion
|
|
} else {
|
|
running
|
|
}
|
|
}
|
|
.navigationTitle(finished ? "Terminé" : "\(index + 1)/\(block.items.count)")
|
|
.onAppear { startCurrent() }
|
|
.onReceive(tick) { _ in step() }
|
|
}
|
|
|
|
// MARK: - Écrans
|
|
|
|
private var running: some View {
|
|
VStack(spacing: 6) {
|
|
Text(current?.name ?? "")
|
|
.font(.headline)
|
|
.multilineTextAlignment(.center)
|
|
.minimumScaleFactor(0.7)
|
|
.lineLimit(3)
|
|
|
|
if let dose = current?.dose, !dose.isEmpty {
|
|
Text(dose)
|
|
.font(.caption2)
|
|
.foregroundStyle(.cyan)
|
|
}
|
|
|
|
ZStack {
|
|
Circle()
|
|
.stroke(Color.gray.opacity(0.25), lineWidth: 6)
|
|
Circle()
|
|
.trim(from: 0, to: progress)
|
|
.stroke(Color.cyan, style: StrokeStyle(lineWidth: 6, lineCap: .round))
|
|
.rotationEffect(.degrees(-90))
|
|
Text(timeLabel)
|
|
.font(.system(size: 30, weight: .semibold, design: .rounded))
|
|
.monospacedDigit()
|
|
}
|
|
.frame(height: 92)
|
|
.padding(.vertical, 2)
|
|
|
|
HStack(spacing: 10) {
|
|
Button { skipBack() } label: { Image(systemName: "backward.fill") }
|
|
.buttonStyle(.plain)
|
|
Button { paused.toggle() } label: {
|
|
Image(systemName: paused ? "play.fill" : "pause.fill")
|
|
.font(.title3)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(.cyan)
|
|
Button { advance(markDone: true) } label: { Image(systemName: "forward.fill") }
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
if let next = nextName {
|
|
Text("Ensuite · \(next)")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
.padding(.horizontal, 4)
|
|
}
|
|
|
|
private var completion: some View {
|
|
VStack(spacing: 10) {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.largeTitle)
|
|
.foregroundStyle(.green)
|
|
Text("\(block.title) terminé")
|
|
.font(.footnote)
|
|
.multilineTextAlignment(.center)
|
|
Button("Fermer") { dismiss() }
|
|
.tint(.cyan)
|
|
}
|
|
.padding()
|
|
}
|
|
|
|
// MARK: - Déroulé
|
|
|
|
private var progress: Double {
|
|
guard let total = current.map({ Double(secondsFor($0)) }), total > 0 else { return 0 }
|
|
return max(0, min(1, remaining / total))
|
|
}
|
|
|
|
private var timeLabel: String {
|
|
// Arrondir avant de décomposer, sinon 59,8 s affiche "0:60".
|
|
let t = max(0, Int(remaining.rounded(.up)))
|
|
return "\(t / 60):" + String(format: "%02d", t % 60)
|
|
}
|
|
|
|
private var nextName: String? {
|
|
index + 1 < block.items.count ? block.items[index + 1].name : nil
|
|
}
|
|
|
|
/// Durée réelle de l'exercice quand l'iPhone l'a transmise ; sinon on
|
|
/// répartit celle du bloc (snapshots persistés avant le 2026-08-03).
|
|
private func secondsFor(_ item: RoutineExercise) -> Int {
|
|
if let d = item.durationSec, d > 0 { return d }
|
|
guard !block.items.isEmpty else { return 45 }
|
|
return max(15, block.durationMin * 60 / block.items.count)
|
|
}
|
|
|
|
private func startCurrent() {
|
|
guard let cur = current else { return }
|
|
remaining = Double(secondsFor(cur))
|
|
}
|
|
|
|
private func step() {
|
|
guard !paused, !finished, current != nil else { return }
|
|
remaining -= 0.2
|
|
if remaining <= 0 {
|
|
advance(markDone: true)
|
|
}
|
|
}
|
|
|
|
private func advance(markDone: Bool) {
|
|
if markDone, let cur = current, !store.done.contains(cur.id) {
|
|
store.toggle(cur.id)
|
|
}
|
|
WKInterfaceDevice.current().play(.success)
|
|
|
|
if index + 1 >= block.items.count {
|
|
finished = true
|
|
WKInterfaceDevice.current().play(.notification)
|
|
return
|
|
}
|
|
index += 1
|
|
startCurrent()
|
|
}
|
|
|
|
private func skipBack() {
|
|
guard index > 0 else {
|
|
startCurrent()
|
|
return
|
|
}
|
|
index -= 1
|
|
startCurrent()
|
|
WKInterfaceDevice.current().play(.click)
|
|
}
|
|
}
|