feat(watch): coches au grain exercice sur la montre
Pendant natif du commit coach_sportif 01bad9e. Les 24 exercices sont cochables un par un pendant la séance, comme sur le web. - RoutineExercise + items dans RoutineBlock ; les exercices voyagent avec le snapshot, rien n'est codé en dur côté watchOS - l'en-tête de bloc est tappable : coche/décoche tous ses exercices d'un coup, pour enchaîner une série sans s'arrêter à chaque ligne - compteur n/N par bloc + progression globale en exercices - CoachRoutineBridge.sanitizeBlocks transmet désormais les items Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -168,6 +168,13 @@ public class CoachRoutineBridgePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
out["num"] = (b["num"] as? NSNumber)?.intValue ?? 0
|
out["num"] = (b["num"] as? NSNumber)?.intValue ?? 0
|
||||||
out["duration_min"] = (b["duration_min"] as? NSNumber)?.intValue ?? 0
|
out["duration_min"] = (b["duration_min"] as? NSNumber)?.intValue ?? 0
|
||||||
out["moment"] = (b["moment"] as? String) ?? "matin"
|
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
|
return out
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import os
|
|||||||
|
|
||||||
private let routineLog = Logger(subsystem: "ch.hypnotruck.coach.watchkitapp", category: "routine")
|
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.
|
/// Un bloc de la routine quotidienne, tel que poussé par l'iPhone.
|
||||||
struct RoutineBlock: Codable, Identifiable, Equatable {
|
struct RoutineBlock: Codable, Identifiable, Equatable {
|
||||||
let id: String
|
let id: String
|
||||||
@@ -11,9 +18,10 @@ struct RoutineBlock: Codable, Identifiable, Equatable {
|
|||||||
let title: String
|
let title: String
|
||||||
let durationMin: Int
|
let durationMin: Int
|
||||||
let moment: String // "matin" | "après la course"
|
let moment: String // "matin" | "après la course"
|
||||||
|
let items: [RoutineExercise]
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, num, title
|
case id, num, title, items
|
||||||
case durationMin = "duration_min"
|
case durationMin = "duration_min"
|
||||||
case moment
|
case moment
|
||||||
}
|
}
|
||||||
@@ -51,6 +59,18 @@ final class RoutineStore: ObservableObject {
|
|||||||
var morningBlocks: [RoutineBlock] { blocks.filter { $0.moment == "matin" } }
|
var morningBlocks: [RoutineBlock] { blocks.filter { $0.moment == "matin" } }
|
||||||
var otherBlocks: [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
|
// MARK: - Persistance locale
|
||||||
|
|
||||||
private func load() {
|
private func load() {
|
||||||
@@ -102,12 +122,25 @@ final class RoutineStore: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Watch → iPhone
|
// MARK: - Watch → iPhone
|
||||||
|
|
||||||
func toggle(_ blockId: String) {
|
func toggle(_ exerciseId: String) {
|
||||||
resetIfStale()
|
resetIfStale()
|
||||||
if done.contains(blockId) {
|
if done.contains(exerciseId) {
|
||||||
done.remove(blockId)
|
done.remove(exerciseId)
|
||||||
} else {
|
} 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()
|
persist()
|
||||||
push()
|
push()
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WatchKit
|
import WatchKit
|
||||||
|
|
||||||
/// Routine quotidienne sur la montre : liste des blocs, cochables au doigt.
|
/// Routine quotidienne sur la montre : les exercices, cochables un par un au fil
|
||||||
/// Chaque coche part vers l'iPhone, qui la POSTe au serveur — l'état est donc
|
/// de la séance. Chaque coche part vers l'iPhone, qui la POSTe au serveur —
|
||||||
/// partagé avec la page /routine et le widget de la home.
|
/// l'état est donc partagé avec la page /routine et le widget de la home.
|
||||||
struct RoutineView: View {
|
struct RoutineView: View {
|
||||||
@StateObject private var store = RoutineStore.shared
|
@StateObject private var store = RoutineStore.shared
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -34,20 +33,21 @@ struct RoutineView: View {
|
|||||||
|
|
||||||
private var list: some View {
|
private var list: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 6) {
|
LazyVStack(spacing: 6, pinnedViews: []) {
|
||||||
progressHeader
|
progressHeader
|
||||||
|
|
||||||
if !store.morningBlocks.isEmpty {
|
ForEach(store.morningBlocks) { block in
|
||||||
sectionTitle("Le matin")
|
blockSection(block)
|
||||||
ForEach(store.morningBlocks) { block in
|
|
||||||
row(block)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !store.otherBlocks.isEmpty {
|
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
|
ForEach(store.otherBlocks) { block in
|
||||||
row(block)
|
blockSection(block)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,10 +67,10 @@ struct RoutineView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var progressHeader: some View {
|
private var progressHeader: some View {
|
||||||
let total = store.blocks.count
|
let total = store.totalExercises
|
||||||
let count = store.done.count
|
let count = store.done.count
|
||||||
return VStack(spacing: 4) {
|
return VStack(spacing: 4) {
|
||||||
Text("\(count) / \(total) blocs")
|
Text("\(count) / \(total) exercices")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundStyle(count == total && total > 0 ? .green : .primary)
|
.foregroundStyle(count == total && total > 0 ? .green : .primary)
|
||||||
ProgressView(value: total > 0 ? Double(count) / Double(total) : 0)
|
ProgressView(value: total > 0 ? Double(count) / Double(total) : 0)
|
||||||
@@ -79,31 +79,55 @@ struct RoutineView: View {
|
|||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sectionTitle(_ text: String) -> some View {
|
/// En-tête de bloc (tap = tout cocher d'un coup) puis ses exercices.
|
||||||
Text(text.uppercased())
|
private func blockSection(_ block: RoutineBlock) -> some View {
|
||||||
.font(.caption2)
|
let complete = store.isComplete(block)
|
||||||
.foregroundStyle(.secondary)
|
return VStack(spacing: 4) {
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
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)
|
.padding(.top, 6)
|
||||||
|
|
||||||
|
ForEach(block.items) { item in
|
||||||
|
exerciseRow(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func row(_ block: RoutineBlock) -> some View {
|
private func exerciseRow(_ item: RoutineExercise) -> some View {
|
||||||
let isDone = store.done.contains(block.id)
|
let isDone = store.done.contains(item.id)
|
||||||
return Button {
|
return Button {
|
||||||
store.toggle(block.id)
|
store.toggle(item.id)
|
||||||
WKInterfaceDevice.current().play(isDone ? .click : .success)
|
WKInterfaceDevice.current().play(isDone ? .click : .success)
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: isDone ? "checkmark.circle.fill" : "circle")
|
Image(systemName: isDone ? "checkmark.circle.fill" : "circle")
|
||||||
.foregroundStyle(isDone ? .green : .secondary)
|
.foregroundStyle(isDone ? .green : .secondary)
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
Text(block.title)
|
Text(item.name)
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.strikethrough(isDone, color: .secondary)
|
.strikethrough(isDone, color: .secondary)
|
||||||
Text("\(block.durationMin) min")
|
if !item.dose.isEmpty {
|
||||||
.font(.caption2)
|
Text(item.dose)
|
||||||
.foregroundStyle(.secondary)
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user