feat: watch design from figma

This commit is contained in:
leon
2026-09-07 16:02:36 +02:00
parent b9930b7378
commit dcecd07d56
48 changed files with 2927 additions and 243 deletions
+17 -12
View File
@@ -115,24 +115,29 @@ struct WatchCoherenceView: View {
struct BreathOrb: View {
let regulation: WatchRegulationProtocol
let startedAt: Date
var showsLabel = true
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
TimelineView(.animation(minimumInterval: 1 / 20)) { context in
let elapsed = context.date.timeIntervalSince(startedAt)
TimelineView(.animation(minimumInterval: reduceMotion ? 1 : 1 / 20)) { context in
let elapsed = WatchDesignMode.frozen ? 0 : context.date.timeIntervalSince(startedAt)
let position = RegulationPhaseEngine.position(in: regulation, elapsedSeconds: elapsed)
let isExpanding = position?.phase.kind == .inhale
let fraction = position?.progress ?? 0
let scale = isExpanding ? 0.6 + fraction * 0.4 : 1 - fraction * 0.4
VStack(spacing: 6) {
let scale = position?.phase.kind == .inhale ? 0.65 + fraction * 0.35 : position?.phase.kind == .exhale ? 1 - fraction * 0.35 : 1
VStack(spacing: 20) {
Text(position?.phase.kind == .exhale ? "Expirez" : position?.phase.kind == .inhale ? "Inspirez" : position?.phase.label ?? "Inspirez")
.opacity(showsLabel ? 1 : 0)
.font(WatchTheme.font(20, weight: .semibold)).multilineTextAlignment(.center)
.minimumScaleFactor(0.75).lineLimit(1).fixedSize(horizontal: false, vertical: true)
ZStack {
Circle().fill(WatchTheme.accent.opacity(0.12)).frame(width: 108, height: 108)
Circle()
.fill(RadialGradient(colors: [WatchTheme.mint, WatchTheme.accent.opacity(0.25)], center: .center, startRadius: 2, endRadius: 52))
.frame(width: 88, height: 88)
.scaleEffect(scale)
Circle().fill(WatchTheme.accent.opacity(0.2)).blur(radius: 6)
Circle().fill(WatchTheme.accent.opacity(0.8))
.overlay(Circle().stroke(WatchTheme.accent.opacity(0.3)))
Circle().fill(.white.opacity(0.8)).frame(width: 8, height: 8)
}
Text(position?.phase.label ?? "Respirez").font(.headline)
}
.frame(width: 68, height: 68).scaleEffect(reduceMotion || WatchDesignMode.frozen ? 1 : scale)
.frame(height: 68).accessibilityHidden(true)
}.frame(maxWidth: .infinity).background { WatchGlow() }
}
}
}
+225 -70
View File
@@ -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é")
}
}
+100 -90
View File
@@ -2,114 +2,124 @@ import SwiftUI
struct WatchMeasurementView: View {
@EnvironmentObject private var model: WatchAppModel
@Environment(\.dismiss) private var dismiss
@State private var errorMessage: String?
@State private var previewStartedAt: Date?
@State private var previewCompleted = false
var body: some View {
ScrollView {
VStack(spacing: 10) {
switch model.measurement.state {
case .idle, .authorizing:
preparation
case let .waitingForFirstSample(secondsRemaining):
liveHeader(bpm: nil, caption: "Recherche du signal · \(secondsRemaining) s")
ProgressView()
cancelButton
case let .collecting(progress):
liveHeader(bpm: model.measurement.currentBPM, caption: "Mesure en cours")
ProgressView(value: progress)
Text("Restez immobile, bras détendu").font(.caption2).foregroundStyle(WatchTheme.secondary)
cancelButton
case .finishing:
ProgressView("Analyse…")
case let .completed(summary):
WatchFigmaCanvas {
Group {
if previewCompleted {
WatchMeasurementResultView()
} else if case let .completed(summary) = model.measurement.state, !WatchDesignMode.enabled {
WatchMeasurementResultView(summary: summary)
case let .failed(message):
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
Text("Mesure interrompue").font(.headline)
Text(message).font(.caption2).foregroundStyle(WatchTheme.secondary)
Button("Recommencer") { model.measurement.resetAfterResult() }
} else {
TimelineView(.periodic(from: .now, by: 1)) { context in
let seconds = remainingSeconds(at: context.date)
VStack(spacing: 0) {
WatchEyebrow(title: "Analyse")
Spacer(minLength: 10)
WatchSignalRing(progress: 1 - Double(seconds) / 60)
Text(watchTime(TimeInterval(seconds)))
.font(WatchTheme.font(15, weight: .bold)).monospacedDigit()
.padding(.top, 12)
Text("Restez immobile")
.font(WatchTheme.font(14)).foregroundStyle(WatchTheme.secondary.opacity(0.6))
.padding(.top, 6)
Spacer(minLength: 12)
Button("Fermer") { close() }.buttonStyle(WatchPillStyle())
}
.onChange(of: seconds) { _, value in
if WatchDesignMode.enabled && !WatchDesignMode.frozen && value == 0 { previewCompleted = true }
}
}
}
}.padding(.horizontal, 20).padding(.vertical, 12)
}
.task { await startMeasurement() }
.alert(errorMessage ?? "", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
Button("Fermer") { close() }
}
.onChange(of: model.measurement.state) { _, state in
if !WatchDesignMode.enabled, case let .failed(message) = state { errorMessage = message }
}
.onDisappear {
guard !WatchDesignMode.enabled else { return }
switch model.measurement.state {
case .waitingForFirstSample, .collecting: model.measurement.cancel()
default: break
}
.padding(.horizontal, 4)
}
.navigationTitle("Mesurer")
.haptikaScreen()
}
private var preparation: some View {
VStack(spacing: 10) {
Image(systemName: "applewatch.radiowaves.left.and.right").font(.system(size: 38)).foregroundStyle(WatchTheme.mint)
Text("60 secondes au repos").font(.headline)
Text("Serrez le bracelet sans gêne et posez l’avant-bras. Haptika attendra jusqu’à 30 s le premier BPM.")
.font(.caption2)
.foregroundStyle(WatchTheme.secondary)
.multilineTextAlignment(.center)
Button("Commencer") {
Task {
do {
try await model.measurement.requestAuthorization()
try await model.measurement.start()
} catch {}
}
}
.buttonStyle(.borderedProminent)
}
}
private func liveHeader(bpm: Double?, caption: String) -> some View {
VStack(spacing: 3) {
Image(systemName: "heart.fill").foregroundStyle(.pink).symbolEffect(.pulse, options: .repeating)
Text(bpm.map { "\(Int($0.rounded()))" } ?? "—")
.font(.system(size: 42, weight: .bold, design: .rounded))
Text(bpm == nil ? caption : "BPM · \(caption)").font(.caption2).foregroundStyle(WatchTheme.secondary)
private func remainingSeconds(at date: Date) -> Int {
if WatchDesignMode.enabled {
guard !WatchDesignMode.frozen, let previewStartedAt else { return 60 }
return max(0, 60 - Int(date.timeIntervalSince(previewStartedAt)))
}
switch model.measurement.state {
case let .collecting(progress): return max(0, Int((60 * (1 - progress)).rounded(.up)))
case .finishing: return 0
default: return 60
}
}
private var cancelButton: some View {
Button("Arrêter", role: .cancel) { model.measurement.cancel() }
.buttonStyle(.bordered)
private func startMeasurement() async {
if WatchDesignMode.enabled { previewStartedAt = Date(); return }
do {
model.measurement.resetAfterResult()
try await model.measurement.requestAuthorization()
guard !Task.isCancelled else { return }
try await model.measurement.start()
} catch { errorMessage = error.localizedDescription }
}
private func close() {
if !WatchDesignMode.enabled { model.measurement.cancel(); model.measurement.resetAfterResult() }
dismiss()
}
}
struct WatchSignalRing: View {
let progress: Double
var body: some View {
ZStack {
Circle().stroke(Color(red: 25 / 255, green: 32 / 255, blue: 44 / 255).opacity(0.4), lineWidth: 6)
Circle().stroke(WatchTheme.accent.opacity(0.8), lineWidth: 6)
Circle().stroke(WatchTheme.accent.opacity(0.6), lineWidth: 2)
Circle().fill(WatchTheme.accent.opacity(0.2)).frame(width: 40, height: 40)
.overlay(Circle().stroke(WatchTheme.accent.opacity(0.3)))
.shadow(color: WatchTheme.accent.opacity(0.3), radius: 8)
Image("WatchHeart").resizable().scaledToFit().frame(width: 16.667, height: 15.292)
}.frame(width: 72, height: 72).padding(3)
.accessibilityLabel("Analyse").accessibilityValue("\(Int(progress * 100)) pour cent")
}
}
struct WatchMeasurementResultView: View {
@EnvironmentObject private var model: WatchAppModel
let summary: WatchMeasurementSummaryV1
private var comparison: WatchBaselineComparison {
model.store.baseline.compare(bpm: summary.averageBPM)
}
@Environment(\.dismiss) private var dismiss
var summary: WatchMeasurementSummaryV1? = nil
var body: some View {
VStack(spacing: 9) {
Image(systemName: summary.isValid ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
.font(.title)
.foregroundStyle(summary.isValid ? WatchTheme.mint : .orange)
Text(summary.averageBPM.map { "\(Int($0.rounded())) BPM" } ?? "Signal incomplet")
.font(.title3.bold())
WatchCard {
VStack(alignment: .leading, spacing: 5) {
Label("Qualité \(Int((summary.quality * 100).rounded())) %", systemImage: "checkmark.seal")
Text("\(Int(summary.minimumBPM ?? 0))–\(Int(summary.maximumBPM ?? 0)) BPM")
Text("Couverture \(Int(summary.coverageSeconds)) s")
Divider()
Text(comparison.frenchLabel).foregroundStyle(WatchTheme.accent)
}
.font(.caption)
}
Text("Mesure optique de bien-être, non médicale. Aucun indice PRV n’est calculé sur la Watch.")
.font(.caption2)
.foregroundStyle(WatchTheme.secondary)
if summary.isValid {
Text("Ressenti").font(.caption2).foregroundStyle(WatchTheme.secondary)
HStack(spacing: 4) {
ForEach(1...5, id: \.self) { value in
Button("\(value)") { model.saveFeeling(value, for: summary) }
.buttonStyle(.borderless)
.foregroundStyle(summary.feeling == value ? WatchTheme.mint : .white)
}
}
}
Button("Terminer") { model.measurement.resetAfterResult() }
.buttonStyle(.borderedProminent)
VStack(spacing: 0) {
WatchEyebrow(title: "RÉSULTAT")
Spacer(minLength: 12)
HStack(alignment: .firstTextBaseline, spacing: 0) {
Text(WatchDesignMode.enabled ? "72" : "—")
.font(WatchTheme.font(64, weight: .bold)).tracking(-3.2)
.foregroundStyle(LinearGradient(colors: [WatchTheme.mint, WatchTheme.accent], startPoint: .topLeading, endPoint: .bottomTrailing))
Text("%").font(WatchTheme.font(14, weight: .medium)).foregroundStyle(WatchTheme.mint.opacity(0.8))
}.foregroundStyle(WatchTheme.accent).shadow(color: WatchTheme.accent.opacity(0.5), radius: 5)
WatchBalanceAxis(position: WatchDesignMode.enabled ? 0.77 : nil).padding(.top, 14)
Text(WatchDesignMode.enabled ? "Équilibre favorable" : "")
.font(WatchTheme.font(10)).foregroundStyle(WatchTheme.secondary).padding(.top, 14)
Spacer(minLength: 12)
Button("Fermer") {
if !WatchDesignMode.enabled { model.measurement.resetAfterResult() }
dismiss()
}.buttonStyle(WatchPillStyle())
}
}
}
+136 -44
View File
@@ -5,7 +5,7 @@ struct WatchRootView: View {
var body: some View {
Group {
if model.hasCompletedOnboarding {
if WatchDesignMode.enabled || model.hasCompletedOnboarding {
WatchHomeView()
} else {
WatchOnboardingView()
@@ -47,68 +47,160 @@ struct WatchOnboardingView: View {
}
}
}
.buttonStyle(.borderedProminent)
.buttonStyle(WatchPillStyle(primary: true))
Button("Plus tard") { model.hasCompletedOnboarding = true }
.font(.system(size: 11)).buttonStyle(.plain).foregroundStyle(WatchTheme.secondary)
}
.padding(.horizontal, 6)
.background(WatchTheme.background.ignoresSafeArea())
}
private func onboardingPage(icon: String, title: String, text: String) -> some View {
VStack(spacing: 10) {
ScrollView { VStack(spacing: 10) {
Image(systemName: icon).font(.system(size: 34)).foregroundStyle(WatchTheme.accent)
Text(title).font(.headline).multilineTextAlignment(.center)
Text(text).font(.caption).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center)
}
Text(text).font(.system(size: 12)).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center).fixedSize(horizontal: false, vertical: true)
}.padding(.horizontal, 6) }
}
}
enum WatchFigmaScreen: String, Hashable, CaseIterable {
case home, measure, result, breathe, prepare, breathing, anchor, anchoring, meditate, meditation, sleep, wake, face
}
struct WatchHomeView: View {
@EnvironmentObject private var model: WatchAppModel
@State private var page = 0
@State private var path: [WatchFigmaScreen] = WatchDesignMode.initialScreen.flatMap { $0 == .home ? nil : [$0] } ?? []
var body: some View {
NavigationStack {
List {
Section {
NavigationLink(value: WatchHomeDestination.measure) { homeRow("Mesurer", "waveform.path.ecg", WatchTheme.mint) }
NavigationLink(value: WatchHomeDestination.coherence) { homeRow("Ma Cohérence", "circle.dotted", WatchTheme.accent) }
NavigationLink(value: WatchHomeDestination.exercises) { homeRow("Exercices", "wind", .cyan) }
NavigationLink(value: WatchHomeDestination.cardiacMirror) { homeRow("Miroir cardiaque", "heart.circle", .pink) }
NavigationLink(value: WatchHomeDestination.history) { homeRow("Historique", "chart.xyaxis.line", .indigo) }
} header: {
VStack(alignment: .leading, spacing: 2) {
Text("HAPTIKA").font(.caption2).foregroundStyle(WatchTheme.accent)
Text("Prendre un temps").font(.headline).foregroundStyle(.white)
}
.textCase(nil)
}
Section {
HStack {
Circle().fill(model.connectivity.isReachable ? WatchTheme.mint : .gray).frame(width: 7, height: 7)
Text(model.connectivity.isReachable ? "iPhone joignable" : "Mode autonome")
}
.font(.caption2)
.foregroundStyle(WatchTheme.secondary)
}
}
.navigationDestination(for: WatchHomeDestination.self) { destination in
switch destination {
case .measure: WatchMeasurementView()
case .coherence: WatchCoherenceView()
case .exercises: WatchExerciseListView()
case .cardiacMirror: WatchCardiacMirrorView()
case .history: WatchHistoryView()
}
NavigationStack(path: $path) {
TabView(selection: $page) {
dashboard.tag(0)
WatchRoutineList(category: .breathe).tag(1)
WatchRoutineList(category: .anchor).tag(2)
WatchRoutineList(category: .meditate).tag(3)
WatchWakeView(onStop: { page = 0 }).tag(4)
WatchFacePreview().tag(5)
}
.tabViewStyle(.verticalPage)
.navigationDestination(for: WatchFigmaScreen.self) { WatchFigmaDestination(screen: $0) }
.haptikaScreen()
.toolbar(.hidden, for: .navigationBar)
.persistentSystemOverlays(.hidden)
}
}
private func homeRow(_ title: String, _ icon: String, _ color: Color) -> some View {
Label {
Text(title).font(.subheadline.weight(.semibold))
} icon: {
Image(systemName: icon).foregroundStyle(color)
private var dashboard: some View {
WatchFigmaCanvas {
VStack(spacing: 9) {
WatchEyebrow(title: "mon état actuel")
ZStack {
Circle().stroke(.white.opacity(0.05), lineWidth: 4)
VStack(spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 0) {
Text(WatchDesignMode.enabled ? "72" : "—")
.font(WatchTheme.font(40, weight: .bold)).tracking(-2.4)
Text("%").font(WatchTheme.font(10, weight: .bold))
}
WatchBalanceAxis(compact: true, position: WatchDesignMode.enabled ? 0.72 : nil)
}
}.frame(width: 99, height: 99).padding(.vertical, 2)
NavigationLink(value: WatchFigmaScreen.measure) {
Text("Mesurer la VCF".uppercased())
}.buttonStyle(WatchPillStyle(primary: true, height: 29, fontSize: 10))
NavigationLink(value: WatchFigmaScreen.measure) {
Text("Actualiser mon état".uppercased())
}.buttonStyle(WatchPillStyle(primary: true, height: 29, fontSize: 10))
}.padding(.horizontal, 24).padding(.top, 14).padding(.bottom, 2)
}
}
}
struct WatchFigmaDestination: View {
let screen: WatchFigmaScreen
var body: some View {
switch screen {
case .home: WatchHomeView()
case .measure: WatchMeasurementView()
case .result: WatchFigmaCanvas { WatchMeasurementResultView().padding(.horizontal, 20) }
case .breathe: WatchRoutineList(category: .breathe)
case .prepare: WatchRoutineView(routine: WatchRoutineCategory.breathe.routines[0])
case .breathing: WatchRoutineView(routine: WatchRoutineCategory.breathe.routines[0], initiallyRunning: true)
case .anchor: WatchRoutineList(category: .anchor)
case .anchoring: WatchRoutineView(routine: WatchRoutineCategory.anchor.routines[0])
case .meditate: WatchRoutineList(category: .meditate)
case .meditation: WatchRoutineView(routine: WatchRoutineCategory.meditate.routines[0])
case .sleep: WatchRoutineView(routine: WatchRoutineCategory.anchor.routines[4])
case .wake: WatchWakeView()
case .face: WatchFacePreview()
}
}
}
struct WatchWakeView: View {
@Environment(\.dismiss) private var dismiss
var onStop: (() -> Void)?
var body: some View {
WatchFigmaCanvas(background: WatchTheme.night) {
VStack(spacing: 12) {
Text("RÉVEIL").font(.system(size: 12, weight: .semibold)).tracking(1.2).foregroundStyle(WatchTheme.secondary.opacity(0.8))
Spacer(minLength: 0)
Group {
if WatchDesignMode.enabled {
Text("7:12")
} else {
TimelineView(.periodic(from: .now, by: 60)) { context in Text(context.date, style: .time) }
}
}.font(.system(size: 44, weight: .bold)).monospacedDigit()
.background { WatchGlow() }
Spacer(minLength: 0)
Button { if let onStop { onStop() } else { dismiss() } } label: {
HStack(spacing: 8) {
Image("WatchStop").resizable().scaledToFit().frame(width: 20, height: 20)
Text("Arrêter")
}
}.buttonStyle(WatchPillStyle(destructive: true, height: 48))
}.padding(.horizontal, 16).padding(.vertical, 10)
}.ignoresSafeArea(.container, edges: .bottom)
}
}
struct WatchFacePreview: View {
var body: some View {
WatchFigmaCanvas {
VStack(spacing: 0) {
Group {
if WatchDesignMode.enabled { Text("10:09") }
else { TimelineView(.periodic(from: .now, by: 60)) { context in Text(context.date, style: .time) } }
}.font(.system(size: 32, weight: .bold))
Text("HAPTIKA").font(.system(size: 10, weight: .light)).tracking(1).foregroundStyle(WatchTheme.accent)
Spacer(minLength: 36)
HStack {
NavigationLink(value: WatchFigmaScreen.measure) {
VStack(spacing: 4) {
Text(WatchDesignMode.enabled ? "68" : "—").font(WatchTheme.font(18, weight: .bold))
.frame(width: 52, height: 52)
.background(WatchTheme.background, in: Circle())
.overlay(Circle().stroke(WatchTheme.accent.opacity(0.12), lineWidth: 2).padding(4))
.shadow(color: WatchTheme.accent.opacity(0.2), radius: 4)
Text("VFC").font(WatchTheme.font(9)).tracking(0.9).foregroundStyle(WatchTheme.secondary)
}
}
Spacer()
NavigationLink {
WatchRoutineView(routine: .init(title: "Sos Stress", protocolID: "sosStress", category: .anchor))
} label: {
VStack(spacing: 4) {
Image("WatchSOS").resizable().scaledToFit().frame(width: 21, height: 21)
.frame(width: 52, height: 52)
.background(Color(red: 0.957, green: 0.247, blue: 0.369).opacity(0.1), in: Circle())
.overlay(Circle().stroke(Color(red: 0.957, green: 0.247, blue: 0.369), lineWidth: 2))
.shadow(color: Color(red: 0.957, green: 0.247, blue: 0.369).opacity(0.5), radius: 6)
Text("SOS").font(WatchTheme.font(9)).tracking(0.9).foregroundStyle(Color(red: 0.957, green: 0.247, blue: 0.369))
}
}
}.buttonStyle(.plain)
}.padding(.horizontal, 23).padding(.vertical, 16).background { WatchGlow() }
}
}
}
+142 -11
View File
@@ -1,29 +1,160 @@
import SwiftUI
import CoreText
enum WatchTheme {
static let background = Color(red: 0.015, green: 0.04, blue: 0.10)
static let card = Color(red: 0.035, green: 0.12, blue: 0.22)
static let accent = Color(red: 0.37, green: 0.79, blue: 0.96)
static let mint = Color(red: 0.38, green: 0.88, blue: 0.78)
static let secondary = Color(red: 0.66, green: 0.73, blue: 0.84)
static func font(_ size: CGFloat, weight: Font.Weight = .regular) -> Font {
.custom("Inter-Regular", size: size).weight(weight)
}
static func registerFonts() {
guard let url = Bundle.main.url(forResource: "Inter", withExtension: "ttf") else { return }
CTFontManagerRegisterFontsForURL(url as CFURL, .process, nil)
}
static let background = Color(hex: 0x0C141F)
static let night = Color(hex: 0x0A1019)
static let card = Color(hex: 0x151C27)
static let accent = Color("AccentColor")
static let mint = Color(hex: 0x79D3FF)
static let secondary = Color(hex: 0xBEC8CF)
static let text = Color(hex: 0xDCE3F3)
static let purple = Color(hex: 0xA78BFA)
static let pink = Color(hex: 0xE94697)
static let stop = Color(hex: 0xFFB4AB)
}
private extension Color {
init(hex: UInt32) {
self.init(red: Double((hex >> 16) & 255) / 255, green: Double((hex >> 8) & 255) / 255, blue: Double(hex & 255) / 255)
}
}
struct WatchCard<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
content
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(WatchTheme.card.opacity(0.82), in: RoundedRectangle(cornerRadius: 18))
.overlay(RoundedRectangle(cornerRadius: 18).stroke(WatchTheme.accent.opacity(0.22)))
content.padding(12).frame(maxWidth: .infinity, alignment: .leading)
.background(WatchTheme.card, in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.white.opacity(0.08)))
}
}
struct WatchPillStyle: ButtonStyle {
var primary = false
var destructive = false
var height: CGFloat = 36
var fontSize: CGFloat = 14
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(destructive ? .system(size: fontSize) : WatchTheme.font(fontSize, weight: .semibold))
.foregroundStyle(destructive ? WatchTheme.stop : primary ? Color(red: 0, green: 0.208, blue: 0.282) : WatchTheme.text)
.frame(maxWidth: .infinity).frame(height: height)
.background(primary ? WatchTheme.accent : destructive ? .clear : Color(red: 0.18, green: 0.208, blue: 0.255), in: Capsule())
.overlay(Capsule().stroke(destructive ? WatchTheme.stop.opacity(0.3) : .clear))
.opacity(configuration.isPressed ? 0.65 : 1)
.contentShape(Capsule())
}
}
struct WatchMenuRow: View {
let title: String
var color: Color = WatchTheme.accent
var body: some View {
HStack {
Text(title).font(WatchTheme.font(16, weight: .medium)).fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 4)
Image("WatchChevron").resizable().scaledToFit().frame(width: 5, height: 8).accessibilityHidden(true)
}
.foregroundStyle(WatchTheme.text)
.padding(.horizontal, 15).frame(minHeight: 50)
.background(LinearGradient(colors: [color.opacity(0.1), WatchTheme.card], startPoint: .leading, endPoint: .trailing))
.overlay(alignment: .leading) { Rectangle().fill(color).frame(width: 4) }
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
struct WatchGlow: View {
var color: Color = WatchTheme.accent
var body: some View {
Circle()
.fill(RadialGradient(colors: [color.opacity(0.24), color.opacity(0)], center: .center, startRadius: 0, endRadius: 100))
.frame(width: 210, height: 210)
.allowsHitTesting(false).accessibilityHidden(true)
}
}
struct WatchEyebrow: View {
let title: String
var body: some View {
Text(title.uppercased()).font(WatchTheme.font(12, weight: .semibold))
.tracking(0.6).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center)
.lineLimit(1).minimumScaleFactor(0.7).fixedSize(horizontal: false, vertical: true)
}
}
func watchTime(_ seconds: TimeInterval) -> String {
let value = max(0, Int(seconds.rounded(.up)))
return String(format: "%02d:%02d", value / 60, value % 60)
}
extension View {
func haptikaScreen() -> some View {
scrollContentBackground(.hidden)
.background(WatchTheme.background.ignoresSafeArea())
.foregroundStyle(WatchTheme.text)
.tint(WatchTheme.accent)
}
}
// Explicit Debug-only presentation fixtures. Never saved or sent to HealthKit.
enum WatchDesignMode {
static var enabled: Bool {
#if DEBUG && targetEnvironment(simulator)
ProcessInfo.processInfo.arguments.contains("--figma-preview")
#else
false
#endif
}
static var initialScreen: WatchFigmaScreen? {
guard enabled,
let index = ProcessInfo.processInfo.arguments.firstIndex(of: "--figma-screen"),
ProcessInfo.processInfo.arguments.indices.contains(index + 1) else { return nil }
return WatchFigmaScreen(rawValue: ProcessInfo.processInfo.arguments[index + 1])
}
static var frozen: Bool { initialScreen != nil }
}
struct WatchFigmaCanvas<Content: View>: View {
var background: Color = WatchTheme.background
@Environment(\.dismiss) private var dismiss
@ViewBuilder var content: Content
var body: some View {
GeometryReader { geometry in
content.frame(width: geometry.size.width, height: geometry.size.height)
}
.scrollContentBackground(.hidden)
.foregroundStyle(WatchTheme.text)
.tint(WatchTheme.accent)
.background(background.ignoresSafeArea())
.toolbar(.hidden, for: .navigationBar)
.persistentSystemOverlays(.hidden)
.accessibilityAction(.escape) { dismiss() }
}
}
struct WatchBalanceAxis: View {
var compact = false
var position: Double?
var body: some View {
HStack(spacing: 4) {
if compact { Circle().fill(Color(red: 0.957, green: 0.247, blue: 0.369)).frame(width: 6, height: 6) }
GeometryReader { geometry in
Capsule().fill(LinearGradient(colors: [compact ? WatchTheme.accent.opacity(0) : Color(red: 0.957, green: 0.247, blue: 0.369), WatchTheme.accent], startPoint: .leading, endPoint: .trailing))
if !compact, let position {
Capsule().fill(WatchTheme.text).frame(width: 4, height: 14)
.position(x: geometry.size.width * min(1, max(0, position)), y: geometry.size.height / 2)
.shadow(color: .white.opacity(0.5), radius: 4)
}
}.frame(width: compact ? 32 : 120, height: compact ? 4 : 6)
if compact { Circle().fill(WatchTheme.accent).frame(width: 6, height: 6) }
}.accessibilityHidden(true)
}
}