diff --git a/ios/App/App/CoachRoutineBridge.swift b/ios/App/App/CoachRoutineBridge.swift index de139f6..b06f64b 100644 --- a/ios/App/App/CoachRoutineBridge.swift +++ b/ios/App/App/CoachRoutineBridge.swift @@ -168,6 +168,13 @@ public class CoachRoutineBridgePlugin: CAPPlugin, CAPBridgedPlugin { out["num"] = (b["num"] as? NSNumber)?.intValue ?? 0 out["duration_min"] = (b["duration_min"] as? NSNumber)?.intValue ?? 0 out["moment"] = (b["moment"] as? String) ?? "matin" + // Les exercices : c'est à ce grain que l'on coche, sur la montre + // comme sur le web. + let rawItems = (b["items"] as? [[String: Any]]) ?? [] + out["items"] = rawItems.compactMap { i -> [String: Any]? in + guard let iid = i["id"] as? String, let name = i["name"] as? String else { return nil } + return ["id": iid, "name": name, "dose": (i["dose"] as? String) ?? ""] + } return out } } diff --git a/ios/App/CoachWatch/RoutineStore.swift b/ios/App/CoachWatch/RoutineStore.swift index 8d75280..37bb764 100644 --- a/ios/App/CoachWatch/RoutineStore.swift +++ b/ios/App/CoachWatch/RoutineStore.swift @@ -4,6 +4,13 @@ import os private let routineLog = Logger(subsystem: "ch.hypnotruck.coach.watchkitapp", category: "routine") +/// Un exercice — c'est à ce grain que l'on coche, sur la montre comme sur le web. +struct RoutineExercise: Codable, Identifiable, Equatable { + let id: String + let name: String + let dose: String +} + /// Un bloc de la routine quotidienne, tel que poussé par l'iPhone. struct RoutineBlock: Codable, Identifiable, Equatable { let id: String @@ -11,9 +18,10 @@ struct RoutineBlock: Codable, Identifiable, Equatable { let title: String let durationMin: Int let moment: String // "matin" | "après la course" + let items: [RoutineExercise] enum CodingKeys: String, CodingKey { - case id, num, title + case id, num, title, items case durationMin = "duration_min" case moment } @@ -51,6 +59,18 @@ final class RoutineStore: ObservableObject { var morningBlocks: [RoutineBlock] { blocks.filter { $0.moment == "matin" } } var otherBlocks: [RoutineBlock] { blocks.filter { $0.moment != "matin" } } + /// Nombre total d'exercices — le dénominateur de la progression. + var totalExercises: Int { blocks.reduce(0) { $0 + $1.items.count } } + + func doneCount(in block: RoutineBlock) -> Int { + block.items.filter { done.contains($0.id) }.count + } + + /// Un bloc est fait quand tous ses exercices le sont. Dérivé, jamais stocké. + func isComplete(_ block: RoutineBlock) -> Bool { + !block.items.isEmpty && doneCount(in: block) == block.items.count + } + // MARK: - Persistance locale private func load() { @@ -102,12 +122,25 @@ final class RoutineStore: ObservableObject { // MARK: - Watch → iPhone - func toggle(_ blockId: String) { + func toggle(_ exerciseId: String) { resetIfStale() - if done.contains(blockId) { - done.remove(blockId) + if done.contains(exerciseId) { + done.remove(exerciseId) } else { - done.insert(blockId) + done.insert(exerciseId) + } + persist() + push() + } + + /// Coche/décoche un bloc entier — pratique quand on enchaîne une série + /// complète sans s'arrêter à chaque exercice. + func toggleBlock(_ block: RoutineBlock) { + resetIfStale() + if isComplete(block) { + block.items.forEach { done.remove($0.id) } + } else { + block.items.forEach { done.insert($0.id) } } persist() push() diff --git a/ios/App/CoachWatch/RoutineView.swift b/ios/App/CoachWatch/RoutineView.swift index 676044b..0567312 100644 --- a/ios/App/CoachWatch/RoutineView.swift +++ b/ios/App/CoachWatch/RoutineView.swift @@ -1,12 +1,11 @@ import SwiftUI import WatchKit -/// Routine quotidienne sur la montre : liste des blocs, cochables au doigt. -/// 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. +/// 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 - @Environment(\.dismiss) private var dismiss var body: some View { Group { @@ -34,20 +33,21 @@ struct RoutineView: View { private var list: some View { ScrollView { - VStack(spacing: 6) { + LazyVStack(spacing: 6, pinnedViews: []) { progressHeader - if !store.morningBlocks.isEmpty { - sectionTitle("Le matin") - ForEach(store.morningBlocks) { block in - row(block) - } + ForEach(store.morningBlocks) { block in + blockSection(block) } if !store.otherBlocks.isEmpty { - sectionTitle("Après la course") + Text("APRÈS LA COURSE") + .font(.caption2) + .foregroundStyle(.orange) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 8) ForEach(store.otherBlocks) { block in - row(block) + blockSection(block) } } @@ -67,10 +67,10 @@ struct RoutineView: View { } private var progressHeader: some View { - let total = store.blocks.count + let total = store.totalExercises let count = store.done.count return VStack(spacing: 4) { - Text("\(count) / \(total) blocs") + Text("\(count) / \(total) exercices") .font(.headline) .foregroundStyle(count == total && total > 0 ? .green : .primary) ProgressView(value: total > 0 ? Double(count) / Double(total) : 0) @@ -79,31 +79,55 @@ struct RoutineView: View { .padding(.vertical, 4) } - private func sectionTitle(_ text: String) -> some View { - Text(text.uppercased()) - .font(.caption2) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) + /// En-tête de bloc (tap = tout cocher d'un coup) 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) + + ForEach(block.items) { item in + exerciseRow(item) + } + } } - private func row(_ block: RoutineBlock) -> some View { - let isDone = store.done.contains(block.id) + private func exerciseRow(_ item: RoutineExercise) -> some View { + let isDone = store.done.contains(item.id) return Button { - store.toggle(block.id) + 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(block.title) + Text(item.name) .font(.footnote) .multilineTextAlignment(.leading) .strikethrough(isDone, color: .secondary) - Text("\(block.durationMin) min") - .font(.caption2) - .foregroundStyle(.secondary) + if !item.dose.isEmpty { + Text(item.dose) + .font(.caption2) + .foregroundStyle(.secondary) + } } Spacer(minLength: 0) }