202 lines
7.6 KiB
Swift
202 lines
7.6 KiB
Swift
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 geht’s") {
|
||
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()
|
||
}
|
||
}
|