forked from fritob/Camper-Monitor
Begleitet das Einparken: verfolgt die Neigung über die Zeit, meldet ob es besser oder schlechter wird, und erinnert an den flachsten Punkt - "vor 4 Sekunden stand das Fahrzeug 0,8 Grad flacher". Bei Erreichen der Toleranz gibt es eine Vibration, das Display bleibt währenddessen wach. Bewusst ohne Positionsbestimmung. Aus einem MEMS-Sensor lässt sich keine brauchbare Strecke ableiten: der Fehler wächst beim zweifachen Integrieren quadratisch mit der Zeit, und im Schritttempo gehen die tatsächlichen Beschleunigungen im Rauschen unter. Gebraucht wird sie auch nicht - beim Einparken lautet die Frage nie "wo stehe ich", sondern "wird es besser". Das steckt vollständig im zeitlichen Verlauf der Neigung, ohne jede Annahme über das Gelände. Die vorhandene Verlaufsaufzeichnung war mit einem Punkt alle fünf Sekunden zu grob; der Assistent führt einen eigenen Ringpuffer, der nur läuft solange die Ansicht offen ist. Sind Spurweite und Radstand hinterlegt, kommt die nötige Höhe der Auffahrkeile dazu. Das ist reine Geometrie und damit exakt. Die Masse stehen im Fahrzeugprofil. Dabei fiel auf, dass sich vorhandene Fahrzeuge praktisch nicht bearbeiten liessen: das ging nur über eine Wischgeste, die niemand findet - erst recht nicht, wenn dort jetzt die Masse einzutragen sind. Die Zeile hat nun einen sichtbaren Knopf dafür, wie bei den WLAN-Einstellungen: antippen wählt aus, das Zeichen daneben öffnet die Bearbeitung. Ausserdem übernimmt der Assistent den anliegenden Messwert beim Öffnen. Sonst stand dort bis zur nächsten Messung "Warte auf Messwerte", obwohl längst welche vorlagen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
204 lines
8.2 KiB
Swift
204 lines
8.2 KiB
Swift
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
|
||
|
||
var body: some View {
|
||
Section {
|
||
VStack(spacing: 16) {
|
||
LevelBubble(pitch: state.pitch, roll: state.roll)
|
||
.frame(maxHeight: 220)
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|