forked from fritob/Camper-Monitor
Ausrichtungs-Assistent fürs Rangieren
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>
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// Der Ausrichtungs-Assistent: begleitet das Rangieren und sagt, ob es besser
|
||||
/// oder schlechter wird und wo es am besten stand.
|
||||
struct AlignmentAssistantView: View {
|
||||
let device: ConfiguredDevice
|
||||
let profile: Profile?
|
||||
|
||||
@Environment(BluetoothManager.self) private var bluetooth
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var assistant = AlignmentAssistant()
|
||||
@State private var didAnnounceTarget = false
|
||||
|
||||
private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
LevelBubble(pitch: state.pitch, roll: state.roll)
|
||||
.frame(maxWidth: 320)
|
||||
.padding(.top, 8)
|
||||
|
||||
adviceBanner
|
||||
|
||||
readings
|
||||
|
||||
if let best = assistant.best, let gain = assistant.improvementAtBest,
|
||||
let seconds = assistant.timeSinceBest {
|
||||
bestPointCard(best: best, gain: gain, seconds: seconds)
|
||||
}
|
||||
|
||||
wedgeSection
|
||||
|
||||
Button("Neu beginnen", systemImage: "arrow.counterclockwise") {
|
||||
assistant.reset()
|
||||
didAnnounceTarget = false
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationTitle("Ausrichtungs-Assistent")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Fertig") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: state) { _, new in
|
||||
guard let pitch = new.pitch, let roll = new.roll else { return }
|
||||
assistant.add(pitch: pitch, roll: roll)
|
||||
announceIfReached()
|
||||
}
|
||||
.onAppear {
|
||||
// Den anliegenden Wert gleich übernehmen. Sonst stünde bis zur
|
||||
// nächsten Messung "Warte auf Messwerte", obwohl längst welche da
|
||||
// sind – die Ansicht wirkt dann tot.
|
||||
if let pitch = state.pitch, let roll = state.roll {
|
||||
assistant.add(pitch: pitch, roll: roll)
|
||||
}
|
||||
// Beim Rangieren schaut man immer wieder aufs Display; es darf
|
||||
// dabei nicht dunkel werden.
|
||||
#if canImport(UIKit)
|
||||
UIApplication.shared.isIdleTimerDisabled = true
|
||||
#endif
|
||||
}
|
||||
.onDisappear {
|
||||
#if canImport(UIKit)
|
||||
UIApplication.shared.isIdleTimerDisabled = false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bausteine
|
||||
|
||||
private var adviceBanner: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: assistant.hasReachedTarget
|
||||
? "checkmark.circle.fill" : assistant.trend.symbol)
|
||||
.font(.title)
|
||||
.foregroundStyle(assistant.hasReachedTarget ? Color.green : Color.accentColor)
|
||||
Text(assistant.advice)
|
||||
.font(.title3.weight(.medium))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding()
|
||||
.background(assistant.hasReachedTarget ? Color.green.opacity(0.15)
|
||||
: Color(.secondarySystemGroupedBackground),
|
||||
in: .rect(cornerRadius: 16))
|
||||
}
|
||||
|
||||
private var readings: some View {
|
||||
HStack(spacing: 12) {
|
||||
reading("Längs", state.pitch)
|
||||
reading("Quer", state.roll)
|
||||
reading("Gesamt", assistant.current?.deviation)
|
||||
}
|
||||
}
|
||||
|
||||
private func reading(_ title: String, _ value: Double?) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(value.map { String(format: "%.1f°", $0) } ?? "–")
|
||||
.font(.title2.weight(.semibold).monospacedDigit())
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private func bestPointCard(best: AlignmentAssistant.Sample,
|
||||
gain: Double,
|
||||
seconds: TimeInterval) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label("Bester Punkt", systemImage: "flag.checkered")
|
||||
.font(.headline)
|
||||
Text(String(format: "Vor %.0f Sekunden stand das Fahrzeug %.1f° flacher (%.1f° statt %.1f°).",
|
||||
seconds.rounded(), gain, best.deviation,
|
||||
assistant.current?.deviation ?? 0))
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var wedgeSection: some View {
|
||||
let across = profile?.trackWidth.flatMap { width in
|
||||
state.roll.flatMap { LevelingWedge.across(roll: $0, trackWidth: width) }
|
||||
}
|
||||
let along = profile?.wheelbase.flatMap { base in
|
||||
state.pitch.flatMap { LevelingWedge.along(pitch: $0, wheelbase: base) }
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Label("Auffahrkeile", systemImage: "triangle.fill")
|
||||
.font(.headline)
|
||||
|
||||
if profile?.trackWidth == nil && profile?.wheelbase == nil {
|
||||
Text("Für die Keilhöhe fehlen Spurweite und Radstand. Beides lässt "
|
||||
+ "sich beim Fahrzeug hinterlegen.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if across == nil && along == nil {
|
||||
Text("Keine Keile nötig.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach([across, along].compactMap { $0 }, id: \.side) { wedge in
|
||||
HStack {
|
||||
Text(wedge.side.text.capitalized)
|
||||
Spacer()
|
||||
Text(String(format: "%.0f cm", wedge.heightInCentimetres))
|
||||
.font(.title3.weight(.semibold).monospacedDigit())
|
||||
}
|
||||
}
|
||||
Text("Höhe unter die tieferstehende Seite, damit das Fahrzeug eben steht.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
|
||||
}
|
||||
|
||||
/// Einmal spürbar melden, wenn die Waage erreicht ist – man schaut beim
|
||||
/// Rangieren nicht dauernd aufs Display.
|
||||
private func announceIfReached() {
|
||||
guard assistant.hasReachedTarget else {
|
||||
didAnnounceTarget = false
|
||||
return
|
||||
}
|
||||
guard !didAnnounceTarget else { return }
|
||||
didAnnounceTarget = true
|
||||
#if canImport(UIKit)
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
extension LevelingWedge.Side: Hashable {}
|
||||
@@ -106,7 +106,9 @@ struct LevelControls: View {
|
||||
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 {
|
||||
@@ -131,6 +133,24 @@ struct LevelControls: View {
|
||||
.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)
|
||||
@@ -157,6 +177,9 @@ struct LevelControls: View {
|
||||
+ "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) {
|
||||
|
||||
@@ -17,8 +17,9 @@ struct ProfilesView: View {
|
||||
row(for: profile)
|
||||
}
|
||||
} footer: {
|
||||
Text("Jedes Fahrzeug hat seine eigenen Geräte. Die App liest immer "
|
||||
+ "nur die des gewählten Fahrzeugs aus.")
|
||||
Text("Antippen wählt das Fahrzeug aus, das ⓘ öffnet Name, Symbol "
|
||||
+ "und Maße. Jedes Fahrzeug hat seine eigenen Geräte, und die "
|
||||
+ "App liest immer nur die des gewählten aus.")
|
||||
}
|
||||
|
||||
Section {
|
||||
@@ -59,30 +60,42 @@ struct ProfilesView: View {
|
||||
}
|
||||
|
||||
private func row(for profile: Profile) -> some View {
|
||||
HStack {
|
||||
HStack(spacing: 12) {
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(profile.name)
|
||||
Text(deviceSummary(for: profile))
|
||||
Text(summary(for: profile))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: profile.symbol)
|
||||
}
|
||||
// Nur dieser Bereich wählt aus; der Knopf rechts bleibt frei.
|
||||
.contentShape(.rect)
|
||||
.onTapGesture {
|
||||
store.selectProfile(profile)
|
||||
bluetooth.refreshConfiguration()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if profile.id == store.activeProfileID {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.tint)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.contentShape(.rect)
|
||||
.onTapGesture {
|
||||
store.selectProfile(profile)
|
||||
bluetooth.refreshConfiguration()
|
||||
|
||||
// Zum Bearbeiten reichte bisher nur das Wischen - das findet
|
||||
// niemand, erst recht nicht für die Fahrzeugmaße.
|
||||
Button {
|
||||
editing = profile
|
||||
} label: {
|
||||
Image(systemName: "info.circle")
|
||||
.font(.title3)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.accessibilityLabel("\(profile.name) bearbeiten")
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
// Das letzte Fahrzeug muss bleiben, sonst hätten Geräte keinen Ort.
|
||||
@@ -98,14 +111,34 @@ struct ProfilesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func deviceSummary(for profile: Profile) -> String {
|
||||
private func summary(for profile: Profile) -> String {
|
||||
let count = store.deviceCount(in: profile)
|
||||
return count == 1 ? "1 Gerät" : "\(count) Geräte"
|
||||
var parts = [count == 1 ? "1 Gerät" : "\(count) Geräte"]
|
||||
if profile.trackWidth != nil || profile.wheelbase != nil {
|
||||
parts.append("Maße hinterlegt")
|
||||
}
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Name und Symbol eines Fahrzeugs ändern.
|
||||
/// Name, Symbol und Maße eines Fahrzeugs ändern.
|
||||
private struct ProfileEditView: View {
|
||||
|
||||
/// Nimmt Komma wie Punkt an – auf einer deutschen Tastatur liegt das Komma
|
||||
/// näher. Unplausible Werte werden verworfen, damit die Keilhöhe nicht
|
||||
/// stillschweigend Unsinn ergibt.
|
||||
static func metres(from text: String) -> Double? {
|
||||
let cleaned = text.replacingOccurrences(of: ",", with: ".")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
guard let value = Double(cleaned), value > 0.5, value < 12 else { return nil }
|
||||
return value
|
||||
}
|
||||
|
||||
static func text(from value: Double?) -> String {
|
||||
guard let value else { return "" }
|
||||
return String(format: "%.2f", value).replacingOccurrences(of: ".", with: ",")
|
||||
}
|
||||
|
||||
let profile: Profile
|
||||
|
||||
@Environment(DeviceStore.self) private var store
|
||||
@@ -113,6 +146,8 @@ private struct ProfileEditView: View {
|
||||
|
||||
@State private var name = ""
|
||||
@State private var symbol = "box.truck"
|
||||
@State private var trackWidth = ""
|
||||
@State private var wheelbase = ""
|
||||
|
||||
private let columns = [GridItem(.adaptive(minimum: 60), spacing: 12)]
|
||||
|
||||
@@ -122,6 +157,31 @@ private struct ProfileEditView: View {
|
||||
Section("Name") {
|
||||
TextField("Name", text: $name)
|
||||
}
|
||||
Section {
|
||||
LabeledContent("Spurweite") {
|
||||
HStack {
|
||||
TextField("z. B. 2,00", text: $trackWidth)
|
||||
.keyboardType(.decimalPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
Text("m").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
LabeledContent("Radstand") {
|
||||
HStack {
|
||||
TextField("z. B. 3,50", text: $wheelbase)
|
||||
.keyboardType(.decimalPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
Text("m").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Fahrzeugmaße")
|
||||
} footer: {
|
||||
Text("Nur für den Ausrichtungs-Assistenten: Aus Neigung und "
|
||||
+ "Abstand errechnet er, wie hoch der Auffahrkeil sein muss. "
|
||||
+ "Ohne Angabe entfällt diese Anzeige.")
|
||||
}
|
||||
|
||||
Section("Symbol") {
|
||||
LazyVGrid(columns: columns, spacing: 12) {
|
||||
ForEach(Profile.symbols, id: \.self) { candidate in
|
||||
@@ -153,6 +213,8 @@ private struct ProfileEditView: View {
|
||||
var updated = profile
|
||||
updated.name = name.trimmingCharacters(in: .whitespaces)
|
||||
updated.symbol = symbol
|
||||
updated.trackWidth = Self.metres(from: trackWidth)
|
||||
updated.wheelbase = Self.metres(from: wheelbase)
|
||||
store.update(updated)
|
||||
dismiss()
|
||||
}
|
||||
@@ -162,6 +224,8 @@ private struct ProfileEditView: View {
|
||||
.onAppear {
|
||||
name = profile.name
|
||||
symbol = profile.symbol
|
||||
trackWidth = Self.text(from: profile.trackWidth)
|
||||
wheelbase = Self.text(from: profile.wheelbase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user