Files
Camper-Monitor/CamperMonitor/Views/AlignmentAssistantView.swift
BiasFandClaude Opus 5 4bc2f5d0f3 Ausrichtungs-Assistent: nicht mehr hängenbleiben
Der Vollbildschirm hing an einer `Section`. SwiftUI zeigt Blätter und
Vollbildschirme dort nicht zuverlässig an - genau der Fehler, an dem
schon der Bestätigungsdialog der Kühlbox scheiterte. Dass es "irgendwie
nicht immer" auftrat, passt dazu. Er hängt jetzt am Knopf selbst. Alle
übrigen Blätter und Dialoge der App sitzen bereits an einer Liste oder
einem Stapel; nur diese Stelle nicht.

Dazu die zweite Hälfte: der Assistent war freigegeben, sobald irgendwann
einmal ein Messwert angekommen war. Nach einem Verbindungsabbruch blieb
der letzte Stand stehen, der Knopf also offen - und der Assistent zeigte
Zahlen, die längst nicht mehr galten. Er verlangt jetzt eine stehende
Verbindung, und der Abschnitt sagt, warum er sonst grau ist.

Reisst die Verbindung während des Rangierens ab, steht das jetzt oben im
Assistenten. Vorher stand die Anzeige einfach still, was von einer
hängenden App nicht zu unterscheiden war.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 09:47:54 +02:00

234 lines
9.0 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
@AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble
private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() }
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
Picker("Darstellung", selection: $displayStyle) {
ForEach(LevelDisplayStyle.allCases) { style in
Text(style.title).tag(style)
}
}
.pickerStyle(.segmented)
.padding(.top, 8)
switch displayStyle {
case .bubble:
LevelBubble(pitch: state.pitch, roll: state.roll)
.frame(maxWidth: 320)
case .vehicle:
VehicleTiltView(pitch: state.pitch, roll: state.roll)
}
if !isLive { disconnectedBanner }
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 isLive: Bool { bluetooth.linkStates[device.id] == .live }
/// Reisst die Verbindung beim Rangieren ab, stehen die Zahlen still. Ohne
/// Hinweis sähe das aus, als hinge die App man rangiert dann nach einem
/// Wert, der längst nicht mehr gilt.
private var disconnectedBanner: some View {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Nicht verbunden").font(.headline)
Text("Die Anzeige steht still, bis der Neigungsmesser wieder da ist.")
.font(.callout)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "antenna.radiowaves.left.and.right.slash")
.font(.title)
.foregroundStyle(.orange)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(Color.orange.opacity(0.15), in: .rect(cornerRadius: 16))
}
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 {}