Files
VanAligneiOS/CamperMonitor/Views/AlignmentAssistantView.swift
T
fototeddyandClaude Sonnet 5 2638438494 Add landscape layouts for level display, vehicle view, and alignment assistant
Rotating the phone into landscape while leveling used to leave a
portrait-shaped column of controls that needed scrolling to take in.
All three level-integration screens now branch on verticalSizeClass:
the bubble/vehicle status in the device screen and the full alignment
assistant (advice, readings, and wedge heights) lay out side-by-side
instead of stacked, sized to fit without scrolling.

The device screen also measures its actual available height via a
GeometryReader and feeds it to the level display so the bubble/vehicle
graphic scales to fill the landscape screen rather than being capped at
a fixed size, and the connection-status section moves to the bottom of
the list in landscape so it doesn't compete with the display for space.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:33:35 +02:00

303 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}