forked from fritob/Camper-Monitor
Neben der Libelle gibt es jetzt eine zweite Darstellung: die Neigung am Fahrzeug selbst, mit den Zeichnungen aus dem Ursprungsprojekt. Umschaltbar und über Starts hinweg gemerkt. Seitenansicht für längs, Heckansicht für quer. Die Heckansicht ist bewusst gewählt, nicht die Frontansicht: sie teilt die Blickrichtung des Fahrers, links im Bild ist also links am Fahrzeug. Von vorne betrachtet wäre es seitenverkehrt und damit beim Ausrichten irreführend. Die Zeichnungen sind weiss gefüllt mit dunklen Linien. Als Schablone eingefärbt wurde daraus eine massive Fläche, weil alles Deckende zur Tintfarbe wird - Fenster, Räder und Konturen verschwanden. Tools/ make-vehicle-art übersetzt deshalb die Helligkeit in Deckkraft: dunkle Linien voll deckend, helle Karosserie nur angedeutet. Eingefärbt bleibt die Zeichnung erhalten und passt sich Hell- wie Dunkeldarstellung an. Die Neigung wird dreifach überhöht gezeigt, sonst wären zwei Grad am Fahrzeug kaum auszumachen. Das steht als Hinweis darunter, zusammen mit der Klarstellung, dass die Gradzahlen echt sind - sonst nähme man den Bildwinkel für den tatsächlichen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
209 lines
8.0 KiB
Swift
209 lines
8.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)
|
||
}
|
||
|
||
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 {}
|