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>
234 lines
9.0 KiB
Swift
234 lines
9.0 KiB
Swift
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 {}
|