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)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,14 @@ struct RoutineExercise: Codable, Identifiable, Equatable {
|
||||
let id: String
|
||||
let name: String
|
||||
let dose: String
|
||||
/// Optionnel : absent des snapshots persistés avant le 2026-08-03. La vue
|
||||
/// guidée retombe alors sur une répartition de la durée du bloc.
|
||||
let durationSec: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, dose
|
||||
case durationSec = "duration_sec"
|
||||
}
|
||||
}
|
||||
|
||||
/// Un bloc de la routine quotidienne, tel que poussé par l'iPhone.
|
||||
|
||||
@@ -6,6 +6,7 @@ import WatchKit
|
||||
/// 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 {
|
||||
@@ -16,6 +17,9 @@ struct RoutineView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Routine")
|
||||
.sheet(item: $guided) { block in
|
||||
RoutineGuidedView(block: block)
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
@@ -79,7 +83,8 @@ struct RoutineView: View {
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
/// En-tête de bloc (tap = tout cocher d'un coup) puis ses exercices.
|
||||
/// 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) {
|
||||
@@ -103,6 +108,15 @@ struct RoutineView: View {
|
||||
.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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user