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 private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() } var body: some View { NavigationStack { ScrollView { VStack(spacing: 24) { LevelBubble(pitch: state.pitch, roll: state.roll) .frame(maxWidth: 320) .padding(.top, 8) 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 {}