feat: implement Haptika PPG and Watch V1
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import Charts
|
||||
import SwiftUI
|
||||
|
||||
struct WatchHistoryView: View {
|
||||
@EnvironmentObject private var model: WatchAppModel
|
||||
|
||||
private var recentSevenDays: [WatchMeasurementSummaryV1] {
|
||||
let cutoff = Date().addingTimeInterval(-7 * 86_400)
|
||||
return model.store.measurements.filter { $0.startedAt >= cutoff && $0.isValid }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if !recentSevenDays.isEmpty {
|
||||
Section("Tendance 7 jours") {
|
||||
Chart(recentSevenDays) { measurement in
|
||||
LineMark(
|
||||
x: .value("Date", measurement.startedAt),
|
||||
y: .value("BPM", measurement.averageBPM ?? 0)
|
||||
)
|
||||
.foregroundStyle(WatchTheme.accent)
|
||||
PointMark(
|
||||
x: .value("Date", measurement.startedAt),
|
||||
y: .value("BPM", measurement.averageBPM ?? 0)
|
||||
)
|
||||
.foregroundStyle(WatchTheme.mint)
|
||||
}
|
||||
.chartYAxis(.hidden)
|
||||
.frame(height: 90)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Mesures") {
|
||||
if model.store.measurements.isEmpty {
|
||||
Text("Aucune mesure synchronisée").font(.caption).foregroundStyle(WatchTheme.secondary)
|
||||
}
|
||||
ForEach(model.store.measurements) { measurement in
|
||||
NavigationLink {
|
||||
WatchMeasurementDetailView(measurement: measurement)
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(measurement.averageBPM.map { "\(Int($0.rounded())) BPM" } ?? "Incomplète").font(.subheadline.bold())
|
||||
Text(measurement.startedAt, style: .date).font(.caption2).foregroundStyle(WatchTheme.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text("\(Int((measurement.quality * 100).rounded())) %").font(.caption2).foregroundStyle(measurement.isValid ? WatchTheme.mint : .orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Séances") {
|
||||
if model.store.sessions.isEmpty {
|
||||
Text("Aucune séance").font(.caption).foregroundStyle(WatchTheme.secondary)
|
||||
}
|
||||
ForEach(model.store.sessions) { session in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(WatchProtocolCatalog.protocolWithID(session.protocolID)?.title ?? session.protocolID).font(.subheadline.bold())
|
||||
Text(session.startedAt, style: .relative).font(.caption2).foregroundStyle(WatchTheme.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Historique")
|
||||
.haptikaScreen()
|
||||
}
|
||||
}
|
||||
|
||||
struct WatchMeasurementDetailView: View {
|
||||
@EnvironmentObject private var model: WatchAppModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let measurement: WatchMeasurementSummaryV1
|
||||
@State private var confirmDelete = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("Mesure Watch") {
|
||||
metric("Moyenne", measurement.averageBPM.map { "\(Int($0.rounded())) BPM" } ?? "—")
|
||||
metric("Min / max", "\(Int(measurement.minimumBPM ?? 0)) / \(Int(measurement.maximumBPM ?? 0))")
|
||||
metric("Qualité", "\(Int((measurement.quality * 100).rounded())) %")
|
||||
metric("Couverture", "\(Int(measurement.coverageSeconds)) s")
|
||||
metric("Appareil", measurement.device.name)
|
||||
if let feeling = measurement.feeling { metric("Ressenti", "\(feeling) / 5") }
|
||||
}
|
||||
Section {
|
||||
Text("Pouls optique Apple Watch. Pas de PRV ni de diagnostic médical.")
|
||||
.font(.caption2).foregroundStyle(WatchTheme.secondary)
|
||||
}
|
||||
Button("Supprimer", role: .destructive) { confirmDelete = true }
|
||||
}
|
||||
.navigationTitle(measurement.startedAt.formatted(date: .abbreviated, time: .shortened))
|
||||
.haptikaScreen()
|
||||
.confirmationDialog("Supprimer cette mesure ?", isPresented: $confirmDelete) {
|
||||
Button("Supprimer", role: .destructive) {
|
||||
model.deleteMeasurement(measurement)
|
||||
dismiss()
|
||||
}
|
||||
Button("Annuler", role: .cancel) {}
|
||||
}
|
||||
}
|
||||
|
||||
private func metric(_ label: String, _ value: String) -> some View {
|
||||
HStack {
|
||||
Text(label).foregroundStyle(WatchTheme.secondary)
|
||||
Spacer()
|
||||
Text(value).multilineTextAlignment(.trailing)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user