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 @Environment(\.verticalSizeClass) private var verticalSizeClass @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() } /// iPhone im Querformat meldet eine kompakte Höhe – das ist das /// zuverlässige Signal dafür, nicht die Geräteausrichtung selbst. private var isLandscape: Bool { verticalSizeClass == .compact } var body: some View { NavigationStack { Group { if isLandscape { landscapeLayout } else { portraitLayout } } .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: - Layouts private var portraitLayout: some View { ScrollView { VStack(spacing: 24) { picker.padding(.top, 8) display 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 resetButton .padding(.bottom, 24) } .padding(.horizontal) .frame(maxWidth: .infinity) } } /// Anzeige links, alles zum Rangieren Nötige rechts – ohne Scrollen, denn /// im Querformat schaut man beiläufig hin, nicht in Ruhe. Die /// Bestpunkt-Karte und der ausführliche Verbindungs-Hinweis bleiben dafür /// dem Hochformat vorbehalten; die Keilhöhen bleiben in jedem Fall sichtbar. private var landscapeLayout: some View { HStack(alignment: .top, spacing: 16) { VStack(spacing: 8) { picker display Spacer(minLength: 0) } .frame(maxWidth: .infinity) VStack(spacing: 8) { if !isLive { compactDisconnectedBanner } adviceBanner readings wedgeSection resetButton } .frame(maxWidth: .infinity) } .padding(12) } // MARK: - Bausteine private var isLive: Bool { bluetooth.linkStates[device.id] == .live } private var picker: some View { Picker("Darstellung", selection: $displayStyle) { ForEach(LevelDisplayStyle.allCases) { style in Text(style.title).tag(style) } } .pickerStyle(.segmented) } @ViewBuilder private var display: some View { switch displayStyle { case .bubble: LevelBubble(pitch: state.pitch, roll: state.roll) .frame(maxWidth: isLandscape ? 220 : 320, maxHeight: isLandscape ? 160 : .infinity) case .vehicle: VehicleTiltView(pitch: state.pitch, roll: state.roll, style: device.vehicleGraphicStyle, compact: isLandscape) } } private var resetButton: some View { Button("Neu beginnen", systemImage: "arrow.counterclockwise") { assistant.reset() didAnnounceTarget = false } .buttonStyle(.bordered) } /// 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)) } /// Kurzform für Querformat: derselbe Hinweis in einer Zeile. private var compactDisconnectedBanner: some View { Label("Nicht verbunden – Anzeige steht still", systemImage: "antenna.radiowaves.left.and.right.slash") .font(.caption.weight(.medium)) .foregroundStyle(.orange) .lineLimit(1) .minimumScaleFactor(0.8) .frame(maxWidth: .infinity, alignment: .leading) .padding(8) .background(Color.orange.opacity(0.15), in: .rect(cornerRadius: 10)) } private var adviceBanner: some View { HStack(spacing: 12) { Image(systemName: assistant.hasReachedTarget ? "checkmark.circle.fill" : assistant.trend.symbol) .font(isLandscape ? .title2 : .title) .foregroundStyle(assistant.hasReachedTarget ? Color.green : Color.accentColor) Text(assistant.advice) .font(isLandscape ? .subheadline.weight(.medium) : .title3.weight(.medium)) .frame(maxWidth: .infinity, alignment: .leading) } .padding(isLandscape ? 10 : 16) .background(assistant.hasReachedTarget ? Color.green.opacity(0.15) : Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16)) } private var readings: some View { HStack(spacing: isLandscape ? 8 : 12) { reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch)) reading("Quer", LevelDirectionFormatting.rollTile(state.roll)) if !isLandscape { reading("Gesamt", LevelDirectionFormatting.magnitude(assistant.current?.deviation)) } } } private func reading(_ title: String, _ text: String) -> some View { VStack(spacing: isLandscape ? 1 : 4) { Text(text) .font((isLandscape ? Font.callout : .title2).weight(.semibold).monospacedDigit()) Text(title) .font(.caption2) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity) .padding(.vertical, isLandscape ? 6 : 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: isLandscape ? 6 : 10) { Label("Auffahrkeile", systemImage: "triangle.fill") .font(isLandscape ? .subheadline.weight(.semibold) : .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(.caption) .foregroundStyle(.secondary) } else if let lift, !lift.isNegligible { WheelLiftPlan(lift: lift, isCompact: isLandscape) .frame(maxWidth: .infinity) if !isLandscape { 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(isLandscape ? 10 : 16) .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 } }