- Dynamic Island now shows only the level icon (compact/minimal); the
bubble, instruction text, and numbers move to the shared lock
screen/CarPlay ActivityView, which iOS renders identically on both
surfaces (no separate CarPlay layout API exists for this).
- Replace +/- signs with a leading direction letter (H/F for Heck-
Front, R/L for rechts-links) via a new shared LevelDirectionFormatting
helper, used by the level screen, alignment assistant, and dashboard
card. The generic "Messwerte" list keeps raw signed values.
- Live Activity's instruction now reads as two lines ("Heck steht
höher 1.8°" / "links steht höher 0.9°") instead of a sentence plus a
separate number column.
- Fix the bubble level graphic's pitch axis being inverted (front/rear
reversed) in all three copies (app, watch, Live Activity widget) —
confirmed against the known-correct VehicleTiltView orientation.
- Dashboard card shows pitch and roll side by side, large, without the
"Längsneigung"/"Querneigung" labels, for the leveling device.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
233 lines
9.1 KiB
Swift
233 lines
9.1 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
|
||
|
||
@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)
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
|