forked from fritob/Camper-Monitor
CamperMonitor (Haupt-Repo) und VanAligneiOS (aus dem gemergten solar-integration-Branch) liefen unter zwei verschiedenen internen Namen, obwohl die App nach aussen längst einheitlich "VanControl Pro" heisst. Jetzt durchgängig VanControl: - Ordner: CamperMonitor/, CamperMonitorWatch/, CamperMonitorComplication/, VanAligneiOSWidget/ → VanControl/, VanControlWatch/, VanControlComplication/, VanControlWidget/ - Xcode-Projekt: CamperMonitor.xcodeproj → VanControl.xcodeproj, alle Targets/Schemes/Produktnamen entsprechend umbenannt - Bundle-Identifier auf Wunsch mitgeändert: de.s0.fototeddy.VanControl* (App noch nicht veröffentlicht); dabei auch die WKCompanionAppBundleIdentifier-Werte korrigiert, die noch das alte de.fritob-Präfix statt des tatsächlichen de.s0.fototeddy-Präfixes trugen - Swift-Dateien/Typen: CamperMonitorApp → VanControlApp, VanAligneiOSWidget* → VanControlWidget* - Config/*-Info.plist umbenannt, README.md/Tools/README.md/run-tests.sh auf die neuen Pfade angepasst Bewusst unverändert: firmware/vanalign und alle Bezüge auf "VanAlign" als Namen der Neigungsmesser-Hardware (eigenständiges Produkt, kein App-Name) sowie der komplette Android/-Ordner. Build (App, Watch, Debug) und Protokoll-Testlauf grün. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
303 lines
12 KiB
Swift
303 lines
12 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
|
||
@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
|
||
}
|
||
}
|