Bisher standen dort zwei Angaben nebeneinander, etwa "rechts 8 cm" und "vorne 4 cm". Das liest sich wie zwei Aufgaben, ist aber eine: Unter "rechts" liegen zwei Räder, unter "vorne" auch, und eines davon ist dasselbe. Ausführen lässt sich das so nicht. Gerechnet wird jetzt über alle vier Aufstandspunkte auf einmal. Aus Spurweite, Radstand und den beiden Winkeln ergeben sich deren Höhen; angehoben wird auf das höchststehende Rad, das liegen bleibt. Aus dem Beispiel oben werden damit 12 cm vorne rechts, 8 cm hinten rechts, 4 cm vorne links und nichts hinten links – dieselbe Geometrie, nur richtig zusammengesetzt. Angezeigt wird das als Draufsicht auf das Fahrzeug (WheelLiftPlan), auf iPhone und Uhr dieselbe Ansicht, auf der Uhr kompakt. LevelingWedge entfällt. Abgesichert in run-tests.sh: reine Querneigung, reine Längsneigung, beides zusammen (16,1 / 9,2 / 7,0 / 0 cm), gespiegelte Vorzeichen und die Fälle ohne Fahrzeugmasse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
233 lines
9.1 KiB
Swift
233 lines
9.1 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 {
|
||
// Gerechnet wird über alle vier Räder auf einmal. Getrennte Angaben für
|
||
// quer und längs beschreiben dasselbe Fahrzeug und lassen sich nicht
|
||
// getrennt ausführen: Unter „rechts" liegen zwei Räder, unter „vorne"
|
||
// auch, und eines davon ist dasselbe.
|
||
let lift: LevelingLift? = {
|
||
guard let width = profile?.trackWidth, let base = profile?.wheelbase,
|
||
let pitch = state.pitch, let roll = state.roll else { return nil }
|
||
return LevelingLift.compute(pitch: pitch, roll: roll,
|
||
trackWidth: width, 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 let lift, !lift.isNegligible {
|
||
WheelLiftPlan(lift: lift)
|
||
.frame(maxWidth: .infinity)
|
||
|
||
Text("Zentimeter unter das jeweilige Rad. Das höchststehende Rad "
|
||
+ "bleibt liegen, die übrigen werden auf seine Höhe gebracht.")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
} else {
|
||
Text("Keine Keile nötig.")
|
||
.font(.callout)
|
||
.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
|
||
}
|
||
}
|
||
|