import SwiftUI /// Grafische Libelle: eine Blase, die zeigt, wohin das Fahrzeug hängt. /// /// Die Blase wandert dorthin, wo das Fahrzeug **höher** steht – so, wie sich /// eine echte Wasserwaage verhält. Wer sie mittig haben will, muss also die /// Gegenseite anheben. struct LevelBubble: View { let pitch: Double? let roll: Double? /// Bis zu welcher Neigung die Anzeige ausschlägt. var range: Double = 6 private var isLevel: Bool { guard let pitch, let roll else { return false } return abs(pitch) <= LevelState.levelTolerance && abs(roll) <= LevelState.levelTolerance } /// Grün nur, wenn es wirklich eben ist. Die Akzentfarbe der App ist selbst /// grün – damit sähe "schief" genauso aus wie "eben". private var bubbleColor: Color { guard let deviation = maxDeviation else { return .secondary } if deviation <= LevelState.levelTolerance { return .green } if deviation <= 2 { return .orange } return .red } private var maxDeviation: Double? { switch (pitch, roll) { case let (p?, r?): return max(abs(p), abs(r)) case let (p?, nil): return abs(p) case let (nil, r?): return abs(r) default: return nil } } var body: some View { GeometryReader { geometry in let side = min(geometry.size.width, geometry.size.height) let radius = side / 2 let bubble = side * 0.16 // Die Blase darf den Rand nicht verlassen, auch bei starker Neigung. let travel = radius - bubble / 2 - 4 let offsetX = clamped(roll) / range * travel let offsetY = clamped(pitch) / range * travel ZStack { Circle() .fill(Color(.secondarySystemFill)) Circle() .strokeBorder(Color.secondary.opacity(0.35), lineWidth: 1) // Ringe als echter Maßstab: der innere markiert die Toleranz, // der mittlere zwei Grad. Ohne Maßstab sagt die Blasenlage // nichts darüber, wie weit es noch ist. let toleranceRadius = max(LevelState.levelTolerance / range * travel, bubble * 0.6) Circle() .strokeBorder(isLevel ? Color.green : Color.secondary.opacity(0.5), lineWidth: isLevel ? 2 : 1) .frame(width: toleranceRadius * 2, height: toleranceRadius * 2) Circle() .strokeBorder(Color.secondary.opacity(0.3), lineWidth: 1) .frame(width: 2.0 / range * travel * 2, height: 2.0 / range * travel * 2) Path { path in path.move(to: CGPoint(x: radius - travel, y: radius)) path.addLine(to: CGPoint(x: radius + travel, y: radius)) path.move(to: CGPoint(x: radius, y: radius - travel)) path.addLine(to: CGPoint(x: radius, y: radius + travel)) } .stroke(Color.secondary.opacity(0.25), lineWidth: 1) Circle() .fill(bubbleColor) .frame(width: bubble, height: bubble) // Positiver Pitch heisst: das Heck steht höher, die Blase // wandert also nach oben – in der Ansicht nach hinten. .offset(x: offsetX, y: -offsetY) .animation(.spring(duration: 0.35), value: offsetX) .animation(.spring(duration: 0.35), value: offsetY) .opacity(pitch == nil && roll == nil ? 0.25 : 1) } .frame(width: side, height: side) .frame(maxWidth: .infinity, maxHeight: .infinity) } .aspectRatio(1, contentMode: .fit) .accessibilityLabel(accessibilityText) } private func clamped(_ value: Double?) -> Double { guard let value else { return 0 } return min(max(value, -range), range) } private var accessibilityText: String { guard let pitch, let roll else { return "Keine Messwerte" } return String(format: "Längsneigung %.1f Grad, Querneigung %.1f Grad", pitch, roll) } } /// Anzeige und Kalibrierung des Neigungsmessers. struct LevelControls: View { let device: ConfiguredDevice let state: LevelState @Environment(BluetoothManager.self) private var bluetooth @Environment(DeviceStore.self) private var store @State private var showResetConfirmation = false @State private var showAssistant = false @AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble var body: some View { Section { VStack(spacing: 16) { Picker("Darstellung", selection: $displayStyle) { ForEach(LevelDisplayStyle.allCases) { style in Text(style.title).tag(style) } } .pickerStyle(.segmented) switch displayStyle { case .bubble: LevelBubble(pitch: state.pitch, roll: state.roll) .frame(maxHeight: 220) case .vehicle: VehicleTiltView(pitch: state.pitch, roll: state.roll) } if let instruction = state.instruction { Label(instruction, systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle") .font(.headline) .foregroundStyle(state.isLevel ? Color.green : Color.primary) .multilineTextAlignment(.center) } HStack(spacing: 24) { reading("Längs", state.pitch) reading("Quer", state.roll) } } .frame(maxWidth: .infinity) .padding(.vertical, 8) } Section { Button { showAssistant = true } label: { Label { VStack(alignment: .leading, spacing: 2) { Text("Ausrichtungs-Assistent") Text("Begleitet das Rangieren") .font(.caption) .foregroundStyle(.secondary) } } icon: { Image(systemName: "car.side.arrowtriangle.up.arrowtriangle.down") } } .disabled(!state.hasReading) } Section { Button("Auf aktuelle Lage kalibrieren", systemImage: "scope") { bluetooth.calibrateLevel(for: device.id) } if !state.isKnownUncalibrated { Button("Kalibrierung zurücksetzen", systemImage: "arrow.uturn.backward", role: .destructive) { showResetConfirmation = true } } } header: { Text("Kalibrierung") } footer: { if state.isCalibrated, let pitchOffset = state.pitchOffset, let rollOffset = state.rollOffset { Text(String(format: "Nullpunkt liegt bei %.1f° längs und %.1f° quer. " + "Zum Kalibrieren das Fahrzeug eben stellen und dann tippen.", pitchOffset, rollOffset)) } else if state.isKnownUncalibrated { Text("Noch nicht kalibriert – die Anzeige zeigt die Lage des Sensors, " + "nicht die des Fahrzeugs. Fahrzeug eben stellen und dann kalibrieren.") } else { // Ältere Firmware gibt die Offsets nicht heraus. Text("Zum Kalibrieren das Fahrzeug eben stellen und dann tippen. " + "Ob bereits kalibriert wurde, meldet dieses Gerät nicht zurück.") } } .fullScreenCover(isPresented: $showAssistant) { AlignmentAssistantView(device: device, profile: store.activeProfile) } .confirmationDialog("Kalibrierung zurücksetzen?", isPresented: $showResetConfirmation, titleVisibility: .visible) { Button("Zurücksetzen", role: .destructive) { bluetooth.resetLevelCalibration(for: device.id) } } message: { Text("Die Anzeige zeigt danach wieder die Lage des Sensors.") } } private func reading(_ title: String, _ value: Double?) -> some View { VStack(spacing: 2) { Text(value.map { String(format: "%.1f°", $0) } ?? "–") .font(.title2.weight(.semibold).monospacedDigit()) Text(title) .font(.caption) .foregroundStyle(.secondary) } } }