Files
coach-ios/ios/App/CoachWatch/RoutineGuidedView.swift
Sylvain Bettinelli f032ee2126 feat(watch): séance guidée au poignet
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>
2026-08-03 16:32:29 +00:00

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)
}
}