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 tiltSide case done(SensorOrientation) case failed(OrientationDetection.Failure) } @State private var step: Step = .intro /// Ruhelage vor der jeweiligen Kippbewegung. @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 .tiltSide: card(icon: "arrow.down.left", title: "Schritt 2 von 2: nach links kippen", text: "Zurück in die Ausgangslage, dann den Sensor so kippen, als " + "würde das Fahrzeug **nach links** hängen – die linke Seite " + "also nach unten.\n\nWieder deutlich kippen und halten.") liveReadout HStack { Button("Zurück") { step = .tiltNose } .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 reference = live step = .tiltSide } private func finishSideStep() { guard let live, let nose = noseChange else { return } // Der Bezug ist die Lage aus Schritt 1, deshalb wird von dort gemessen. 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() } }