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, style: device.vehicleGraphicStyle) } 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", LevelDirectionFormatting.pitchTile(state.pitch)) reading("Quer", LevelDirectionFormatting.rollTile(state.roll)) reading("Gesamt", LevelDirectionFormatting.magnitude(assistant.current?.deviation)) } } private func reading(_ title: String, _ text: String) -> some View { VStack(spacing: 4) { Text(text) .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 } }