import SwiftUI enum WatchRoutineCategory: String, CaseIterable, Identifiable { case breathe, anchor, meditate var id: String { rawValue } var title: String { switch self { case .breathe: "Respirer"; case .anchor: "Ancrer"; case .meditate: "Méditation" } } var color: Color { switch self { case .breathe: WatchTheme.accent; case .anchor: WatchTheme.purple; case .meditate: WatchTheme.pink } } var asset: String { switch self { case .breathe: "WatchBreath"; case .anchor: "WatchAnchor"; case .meditate: "WatchMeditation" } } var routines: [WatchRoutine] { switch self { case .breathe: return [WatchRoutine(title: "Cohérence", protocolID: "coherence55", category: self), WatchRoutine(title: "Calme express", protocolID: "relax46", category: self), WatchRoutine(title: "Équilibre", protocolID: "box4444", category: self), WatchRoutine(title: "4·7·8", protocolID: "breathing478", category: self)] case .anchor: return [WatchRoutine(title: "Micro-Ancrage", protocolID: "microAnchor", category: self), WatchRoutine(title: "Sos Stress", protocolID: "sosStress", category: self), WatchRoutine(title: "Apaiser", category: self), WatchRoutine(title: "Booster", category: self), WatchRoutine(title: "Endormissement", category: self)] case .meditate: return ["Découverte", "Présence", "Sensation", "Atterrissage", "Libre"].map { WatchRoutine(title: $0, category: self) } } } } struct WatchRoutine: Identifiable { let title: String var protocolID: String? = nil let category: WatchRoutineCategory var id: String { category.rawValue + title } var regulation: WatchRegulationProtocol? { protocolID.flatMap(WatchProtocolCatalog.protocolWithID) } var isSleep: Bool { title == "Endormissement" } var summary: String { title == "Découverte" ? "Cinq minutes de vibrations\nespacées. Ressenter,\nsans rien modifier." : "" } } struct WatchExerciseListView: View { var body: some View { TabView { ForEach(WatchRoutineCategory.allCases) { category in WatchRoutineList(category: category) } }.tabViewStyle(.verticalPage).haptikaScreen() } } struct WatchRoutineList: View { let category: WatchRoutineCategory var body: some View { WatchFigmaCanvas { ScrollView { VStack(spacing: 8) { HStack(spacing: 8) { Text(category.title).font(WatchTheme.font(20, weight: .semibold)) Image(category.asset).resizable().scaledToFit().frame(width: 19, height: 17).accessibilityHidden(true) }.foregroundStyle(category.color).padding(.bottom, 6) ForEach(category.routines) { routine in NavigationLink { WatchRoutineView(routine: routine) } label: { WatchMenuRow(title: routine.title, color: category.color) } .buttonStyle(.plain) } }.padding(.horizontal, 16).padding(.top, 36).padding(.bottom, 12) }.scrollIndicators(.hidden).contentMargins(.vertical, 0, for: .scrollContent) .ignoresSafeArea(.container, edges: .top) } } } struct WatchExercisePlayerView: View { let regulation: WatchRegulationProtocol var body: some View { WatchRoutineView(routine: .init(title: regulation.title, protocolID: regulation.id, category: regulation.family == .breathe ? .breathe : .anchor)) } } struct WatchRoutineView: View { @EnvironmentObject private var model: WatchAppModel @Environment(\.dismiss) private var dismiss let routine: WatchRoutine var initiallyRunning = false @State private var startedAt: Date? @State private var errorMessage: String? @State private var sessionSaved = false @State private var finished = false @State private var blackScreen = true @State private var screenHidden = false @State private var focus = true var body: some View { WatchFigmaCanvas(background: routine.isSleep ? WatchTheme.night : WatchTheme.background) { ZStack { if routine.category == .meditate { meditation } else if routine.category == .anchor || startedAt != nil { session } else { preparation } if screenHidden { Color.black.ignoresSafeArea() Button { screenHidden = false } label: { Color.black.frame(maxWidth: .infinity, maxHeight: .infinity).contentShape(Rectangle()) }.buttonStyle(.plain).accessibilityLabel("Afficher les commandes de la séance") } } } .ignoresSafeArea(.container, edges: routine.isSleep ? .bottom : []) .task { if initiallyRunning || routine.category == .anchor { await start() } } .alert(errorMessage ?? "", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) { Button("Fermer") { dismiss() } } .onChange(of: model.haptics.isRunning) { wasRunning, running in if wasRunning && !running && startedAt != nil && !finished && !WatchDesignMode.enabled { saveSession(state: .completed) finished = true dismiss() } } .onDisappear { guard startedAt != nil, !finished, !WatchDesignMode.enabled else { return } saveSession(state: .cancelled) model.haptics.cancel() } } private var preparation: some View { VStack(spacing: 0) { WatchEyebrow(title: routine.title == "Cohérence" ? "Cohérence Cardiaque" : routine.title) .tracking(-0.6) Spacer(minLength: 14) VStack(spacing: 5) { Image("WatchPrepare").resizable().scaledToFit().frame(width: 17, height: 14) Text("Prêt pour la séance ?").font(WatchTheme.font(7)) } .frame(width: 110, height: 86) .overlay(RoundedRectangle(cornerRadius: 24).stroke(.white.opacity(0.05), lineWidth: 4)) Toggle(isOn: $blackScreen) { Text("Mode écran noir").font(WatchTheme.font(10, weight: .semibold)) } .toggleStyle(WatchCompactToggleStyle()) .padding(.top, 10) Spacer(minLength: 16) Button("Commencer".uppercased()) { Task { await start() } } .buttonStyle(WatchPillStyle(primary: true, height: 29, fontSize: 10)) }.padding(.horizontal, 24).padding(.vertical, 12) } private var meditation: some View { VStack(spacing: 10) { Text(routine.title).font(WatchTheme.font(24, weight: .medium)).tracking(-0.6) if !routine.summary.isEmpty { Text(routine.summary).font(WatchTheme.font(12)) .foregroundStyle(WatchTheme.secondary.opacity(0.8)) .multilineTextAlignment(.center).fixedSize(horizontal: false, vertical: true) } }.frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.horizontal, 20).background { WatchGlow() } } private var session: some View { VStack(spacing: 0) { if routine.isSleep { Color.clear.frame(maxWidth: .infinity, maxHeight: .infinity) } else if routine.category == .breathe, let regulation = routine.regulation { Spacer(minLength: 0) BreathOrb(regulation: regulation, startedAt: startedAt ?? Date(), showsLabel: focus) HStack(spacing: 0) { modeButton("FOCUS", selected: focus) { focus = true } modeButton("LIBRE", selected: !focus) { focus = false } } .padding(4).frame(width: 132, height: 24) .background(WatchTheme.card, in: Capsule()) .overlay(Capsule().stroke(.white.opacity(0.05))) .padding(.top, 12) Spacer(minLength: 16) } else { Spacer(minLength: 0) VStack(spacing: 8) { Text(startedAt != nil || WatchDesignMode.enabled ? "Session en cours" : "").font(.system(size: 14)).foregroundStyle(WatchTheme.secondary.opacity(0.6)) TimelineView(.periodic(from: .now, by: 1)) { context in let duration = routine.regulation?.durationSeconds ?? 785 let remaining = max(0, duration - context.date.timeIntervalSince(startedAt ?? context.date)) Text(WatchDesignMode.frozen ? "13:05" : startedAt == nil ? "—" : watchTime(remaining)) .font(.system(size: 56)).monospacedDigit().foregroundStyle(WatchTheme.mint) .shadow(color: WatchTheme.accent.opacity(0.15), radius: 6) } HStack(spacing: 4) { ForEach(0..<3) { index in Circle().fill(WatchTheme.accent.opacity(index == 0 ? 0.45 : 0.15)).frame(width: 6, height: 6) } }.accessibilityHidden(true) } Spacer(minLength: 28) } Button { stop() } label: { HStack(spacing: 8) { if routine.category != .breathe { Image("WatchStop").resizable().scaledToFit().frame(width: 20, height: 20) } Text(routine.category == .breathe ? "Terminer" : "Arrêter") } }.buttonStyle(WatchPillStyle(destructive: routine.category != .breathe, height: routine.category == .breathe ? 36 : 48)) }.frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.horizontal, routine.category == .breathe ? 28 : 16).padding(.vertical, 12) } private func modeButton(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { Text(label).font(WatchTheme.font(8, weight: .semibold)).tracking(0.6) .foregroundStyle(selected ? Color(red: 0, green: 0.275, blue: 0.369) : WatchTheme.mint) .frame(maxWidth: .infinity, minHeight: 16) .background(selected ? WatchTheme.mint : .clear, in: Capsule()) }.buttonStyle(.plain).accessibilityAddTraits(selected ? .isSelected : []) } private func start() async { if WatchDesignMode.enabled { startedAt = Date() screenHidden = blackScreen && routine.category == .breathe && !initiallyRunning return } guard let regulation = routine.regulation else { return } do { try await model.haptics.start(protocol: regulation) startedAt = Date() screenHidden = blackScreen && routine.category == .breathe && !initiallyRunning } catch { errorMessage = error.localizedDescription } } private func stop() { saveSession(state: .cancelled) finished = true if !WatchDesignMode.enabled { model.haptics.cancel() } dismiss() } private func saveSession(state: WatchRegulationSessionState) { guard !WatchDesignMode.enabled, !sessionSaved, let startedAt, let regulation = routine.regulation else { return } sessionSaved = true model.enqueueSession(.init(protocolID: regulation.id, startedAt: startedAt, endedAt: Date(), durationSeconds: model.haptics.elapsedSeconds, state: state)) } } struct WatchCompactToggleStyle: ToggleStyle { func makeBody(configuration: Configuration) -> some View { Button { configuration.isOn.toggle() } label: { HStack { configuration.label Spacer(minLength: 8) Capsule().fill(configuration.isOn ? WatchTheme.mint : WatchTheme.secondary.opacity(0.3)) .frame(width: 29, height: 15) .overlay(alignment: configuration.isOn ? .trailing : .leading) { Circle().fill(configuration.isOn ? Color(red: 0, green: 0.208, blue: 0.282) : WatchTheme.text) .frame(width: 11, height: 11).padding(2) } }.padding(.horizontal, 10).frame(height: 29) .background(WatchTheme.card.opacity(0.6), in: Capsule()) .overlay(Capsule().stroke(.white.opacity(0.08))) }.buttonStyle(.plain).accessibilityValue(configuration.isOn ? "Activé" : "Désactivé") } }