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 unten – in der Ansicht nach hinten. // Muss zur "Fahrzeug"-Ansicht (VehicleTiltView) passen. .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 @Environment(LevelActivityManager.self) private var levelActivity @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, style: device.vehicleGraphicStyle) } 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", LevelDirectionFormatting.pitchTile(state.pitch)) reading("Quer", LevelDirectionFormatting.rollTile(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") } } // Der Assistent lebt von laufenden Messwerten. Ein einmal // empfangener Wert genügt nicht: nach einem Verbindungsabbruch // bliebe er stehen und die Ansicht sähe eingefroren aus. .disabled(!isLive || !state.hasReading) // Der Vollbildschirm gehört an den Knopf, nicht an den Abschnitt. // SwiftUI zeigt Blätter und Vollbildschirme an einer `Section` // nicht zuverlässig an – dasselbe hatte die Kühlbox schon. .fullScreenCover(isPresented: $showAssistant) { AlignmentAssistantView(device: device, profile: store.activeProfile) } } footer: { if !isLive { Text("Der Assistent braucht laufende Messwerte. Der Neigungsmesser " + "ist gerade nicht verbunden.") } } Section { Toggle(isOn: liveActivityBinding) { Label { VStack(alignment: .leading, spacing: 2) { Text("Live Activity") Text("Sperrbildschirm, Dynamic Island und CarPlay") .font(.caption) .foregroundStyle(.secondary) } } icon: { Image(systemName: "widget.small") } } // Ohne laufende Messwerte gäbe es nichts anzuzeigen – und beim // Umschalten hätte die Activity sofort einen veralteten Stand. .disabled(!isLive || !state.hasReading) } footer: { Text(!isLive ? "Braucht laufende Messwerte. Der Neigungsmesser ist gerade nicht verbunden." : "Zeigt die Neigung, solange sie läuft – auch bei gesperrtem Bildschirm. " + "Ist das iPhone mit CarPlay verbunden, erscheint sie automatisch auch dort.") } // Einbaulage und Nullpunkt liegen auf einer eigenen Seite. Direkt unter // der Libelle verstellte ein Fehlgriff beim Ablesen den Nullpunkt. Section { NavigationLink { LevelSetupView(device: device) } label: { LabeledContent { Text(setupSummary).font(.caption) } label: { Label("Neigungsmesser einrichten", systemImage: "slider.horizontal.3") } } } footer: { Text("Einbaulage des Sensors und Nullpunkt der Anzeige.") } } private var isLive: Bool { bluetooth.linkStates[device.id] == .live } private var liveActivityBinding: Binding { Binding( get: { levelActivity.isActive(for: device.id) }, set: { isOn in if isOn { levelActivity.start(deviceID: device.id, deviceName: device.name, state: state) } else { levelActivity.end() } } ) } /// Was auf der Einrichtungsseite zu holen ist – der Nullpunkt zuerst, denn /// ohne ihn stimmt die Anzeige nicht. private var setupSummary: String { state.isKnownUncalibrated ? "Nullpunkt fehlt" : device.sensorOrientation.summary } private func reading(_ title: String, _ text: String) -> some View { VStack(spacing: 2) { Text(text) .font(.title2.weight(.semibold).monospacedDigit()) Text(title) .font(.caption) .foregroundStyle(.secondary) } } }