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>
This commit is contained in:
172
ios/App/CoachWatch/RoutineGuidedView.swift
Normal file
172
ios/App/CoachWatch/RoutineGuidedView.swift
Normal file
@@ -0,0 +1,172 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user