Files
VanAligneiOS/CamperMonitor/Views/SensorSetupView.swift
2026-09-04 21:30:51 +02:00

202 lines
7.6 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
/// Führt durch die Einrichtung der Einbaulage des Neigungsmessers.
///
/// Der Sensor kann quer, gedreht oder kopfüber sitzen. Statt die Lage aus einer
/// Liste raten zu lassen, wird sie gemessen: zweimal kippen, einmal um jede
/// Achse, und aus der Reaktion ergibt sich die Zuordnung.
struct SensorSetupView: View {
let device: ConfiguredDevice
@Environment(DeviceStore.self) private var store
@Environment(BluetoothManager.self) private var bluetooth
@Environment(\.dismiss) private var dismiss
private enum Step {
case intro
case tiltNose
case settle
case tiltSide
case done(SensorOrientation)
case failed(OrientationDetection.Failure)
}
@State private var step: Step = .intro
/// Ruhelage, auf die beide Kippbewegungen bezogen werden.
@State private var reference = OrientationDetection.Reading(pitch: 0, roll: 0)
@State private var noseChange: OrientationDetection.Reading?
private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() }
private var live: OrientationDetection.Reading? {
guard let pitch = state.rawPitch, let roll = state.rawRoll else { return nil }
return OrientationDetection.Reading(pitch: pitch, roll: roll)
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
content
}
.padding()
.frame(maxWidth: .infinity)
}
.background(Color(.systemGroupedBackground))
.navigationTitle("Einbaulage")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
}
}
}
}
@ViewBuilder
private var content: some View {
switch step {
case .intro:
card(icon: "arrow.triangle.2.circlepath",
title: "Einbaulage bestimmen",
text: "Sitzt der Sensor quer oder verdreht im Fahrzeug, meldet er "
+ "die Neigung vertauscht. Um das zu klären, wird er gleich "
+ "zweimal gekippt.\n\nBaue ihn dazu so ein oder halte ihn so, "
+ "wie er später sitzen soll. Er muss nicht angeschraubt sein "
+ "nur die Ausrichtung muss stimmen.")
liveReadout
Button("Los gehts") {
reference = live ?? .init(pitch: 0, roll: 0)
step = .tiltNose
}
.buttonStyle(.borderedProminent)
.disabled(live == nil)
case .tiltNose:
card(icon: "arrow.down.forward",
title: "Schritt 1 von 2: nach vorne kippen",
text: "Kippe den Sensor so, als würde das Fahrzeug **vorne "
+ "abwärts** stehen die Front also nach unten.\n\n"
+ "Deutlich kippen, etwa eine Handbreit, und in dieser Lage "
+ "halten. Dann weiter.")
liveReadout
Button("Weiter") { finishNoseStep() }
.buttonStyle(.borderedProminent)
.disabled(live == nil)
case .settle:
card(icon: "equal.circle",
title: "Zurück in die Ruhelage",
text: "Stelle den Sensor wieder so hin wie am Anfang und lass "
+ "ihn kurz ruhen.\n\nVon hier aus wird die zweite Bewegung "
+ "gemessen.")
liveReadout
Button("Weiter") {
reference = live ?? reference
step = .tiltSide
}
.buttonStyle(.borderedProminent)
.disabled(live == nil)
case .tiltSide:
card(icon: "arrow.down.left",
title: "Schritt 2 von 2: nach links kippen",
text: "Kippe den Sensor jetzt so, als würde das Fahrzeug **nach "
+ "links** hängen die linke Seite also nach unten.\n\n"
+ "Wieder deutlich kippen und in dieser Lage halten.")
liveReadout
HStack {
Button("Zurück") { step = .settle }
.buttonStyle(.bordered)
Button("Fertig") { finishSideStep() }
.buttonStyle(.borderedProminent)
.disabled(live == nil)
}
case .done(let orientation):
card(icon: "checkmark.circle.fill",
title: "Einbaulage erkannt",
text: "Ergebnis: **\(orientation.summary)**.\n\nDie Anzeige rechnet "
+ "die Werte des Sensors ab jetzt auf die Achsen des Fahrzeugs "
+ "um. Vergiss nicht, anschliessend im ebenen Stand zu "
+ "kalibrieren.")
Button("Übernehmen") { save(orientation) }
.buttonStyle(.borderedProminent)
case .failed(let failure):
card(icon: "exclamationmark.triangle.fill",
title: "Das hat nicht geklappt",
text: failure.message)
Button("Nochmal versuchen") { step = .intro }
.buttonStyle(.borderedProminent)
}
}
private func card(icon: String, title: String, text: String) -> some View {
VStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 44))
.foregroundStyle(.tint)
Text(title)
.font(.title3.weight(.semibold))
.multilineTextAlignment(.center)
Text(.init(text))
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding()
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
}
/// Was der Sensor gerade meldet ohne Umrechnung, denn die wird hier ja
/// erst bestimmt.
private var liveReadout: some View {
HStack(spacing: 16) {
value("Achse A", state.rawPitch)
value("Achse B", state.rawRoll)
}
}
private func value(_ title: String, _ reading: Double?) -> some View {
VStack(spacing: 4) {
Text(reading.map { String(format: "%.1f°", $0) } ?? "")
.font(.title3.weight(.semibold).monospacedDigit())
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 12))
}
// MARK: - Ablauf
private func finishNoseStep() {
guard let live else { return }
noseChange = live - reference
// Der Bezug bleibt die Ruhelage. Von der gekippten Lage aus zu messen
// wäre falsch: die zweite Messung enthielte dann das Zurückkippen aus
// der ersten, und beide Achsen schlügen aus.
step = .settle
}
private func finishSideStep() {
guard let live, let nose = noseChange else { return }
let side = live - reference
switch OrientationDetection.orientation(nose: nose, side: side) {
case .success(let orientation): step = .done(orientation)
case .failure(let failure): step = .failed(failure)
}
}
private func save(_ orientation: SensorOrientation) {
var updated = device
updated.sensorOrientation = orientation
store.update(updated)
bluetooth.updateSensorOrientation(for: updated)
dismiss()
}
}