feat: watch design from figma
This commit is contained in:
@@ -1,102 +1,257 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WatchExerciseListView: View {
|
||||
private var hapticProtocols: [WatchRegulationProtocol] {
|
||||
WatchProtocolCatalog.protocols.filter { $0.mode == .haptic }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List(hapticProtocols) { regulation in
|
||||
NavigationLink {
|
||||
WatchExercisePlayerView(regulation: regulation)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(regulation.title).font(.subheadline.bold())
|
||||
Text(formatDuration(regulation.durationSeconds)).font(.caption2).foregroundStyle(WatchTheme.secondary)
|
||||
}
|
||||
}
|
||||
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) }
|
||||
}
|
||||
.navigationTitle("Exercices")
|
||||
.haptikaScreen()
|
||||
}
|
||||
}
|
||||
|
||||
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 regulation: WatchRegulationProtocol
|
||||
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 {
|
||||
ScrollView {
|
||||
VStack(spacing: 10) {
|
||||
if model.haptics.isRunning, let startedAt {
|
||||
BreathOrb(regulation: regulation, startedAt: startedAt)
|
||||
ProgressView(value: model.haptics.elapsedSeconds, total: regulation.durationSeconds)
|
||||
Text("\(Int(max(0, regulation.durationSeconds - model.haptics.elapsedSeconds))) s")
|
||||
.font(.caption.monospacedDigit()).foregroundStyle(WatchTheme.secondary)
|
||||
Button("Arrêter", role: .cancel) {
|
||||
saveSession(state: .cancelled)
|
||||
model.haptics.cancel()
|
||||
}
|
||||
} else if startedAt != nil {
|
||||
Image(systemName: "checkmark.circle.fill").font(.title).foregroundStyle(WatchTheme.mint)
|
||||
Text("Exercice terminé").font(.headline)
|
||||
Button("Terminer") { dismiss() }.buttonStyle(.borderedProminent)
|
||||
.onAppear { saveSession(state: .completed) }
|
||||
WatchFigmaCanvas(background: routine.isSleep ? WatchTheme.night : WatchTheme.background) {
|
||||
ZStack {
|
||||
if routine.category == .meditate {
|
||||
meditation
|
||||
} else if routine.category == .anchor || startedAt != nil {
|
||||
session
|
||||
} else {
|
||||
Text(regulation.eyebrow).font(.caption2.bold()).foregroundStyle(WatchTheme.accent)
|
||||
Text(regulation.title).font(.headline).multilineTextAlignment(.center)
|
||||
Text(regulation.summary).font(.caption2).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center)
|
||||
Text("Aucune mesure cardiaque pendant les vibrations.").font(.caption2.bold()).foregroundStyle(.orange)
|
||||
Button("Commencer") { start() }.buttonStyle(.borderedProminent)
|
||||
NavigationLink("Parcours avant / après") {
|
||||
WatchBeforeAfterFlowView(regulation: regulation)
|
||||
}
|
||||
.font(.caption)
|
||||
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")
|
||||
}
|
||||
if let errorMessage { Text(errorMessage).font(.caption2).foregroundStyle(.orange) }
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
.navigationTitle(regulation.title)
|
||||
.haptikaScreen()
|
||||
.onDisappear {
|
||||
if model.haptics.isRunning {
|
||||
saveSession(state: .cancelled)
|
||||
model.haptics.cancel()
|
||||
.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 func start() {
|
||||
let now = Date()
|
||||
Task {
|
||||
do {
|
||||
try await model.haptics.start(protocol: regulation)
|
||||
startedAt = now
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
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 !sessionSaved, let startedAt else { return }
|
||||
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
|
||||
))
|
||||
model.enqueueSession(.init(protocolID: regulation.id, startedAt: startedAt, endedAt: Date(),
|
||||
durationSeconds: model.haptics.elapsedSeconds, state: state))
|
||||
}
|
||||
}
|
||||
|
||||
private func formatDuration(_ duration: TimeInterval) -> String {
|
||||
let seconds = Int(duration)
|
||||
return seconds < 60 ? "\(seconds) s" : "\(seconds / 60) min \(seconds % 60 == 0 ? "" : "\(seconds % 60) s")"
|
||||
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é")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user