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
+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())
}
}
}