- 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>
253 lines
10 KiB
Swift
253 lines
10 KiB
Swift
import SwiftUI
|
||
|
||
/// Grafische Libelle: eine Blase, die zeigt, wohin das Fahrzeug hängt.
|
||
///
|
||
/// Die Blase wandert dorthin, wo das Fahrzeug **höher** steht – so, wie sich
|
||
/// eine echte Wasserwaage verhält. Wer sie mittig haben will, muss also die
|
||
/// Gegenseite anheben.
|
||
struct LevelBubble: View {
|
||
let pitch: Double?
|
||
let roll: Double?
|
||
/// Bis zu welcher Neigung die Anzeige ausschlägt.
|
||
var range: Double = 6
|
||
|
||
private var isLevel: Bool {
|
||
guard let pitch, let roll else { return false }
|
||
return abs(pitch) <= LevelState.levelTolerance
|
||
&& abs(roll) <= LevelState.levelTolerance
|
||
}
|
||
|
||
/// Grün nur, wenn es wirklich eben ist. Die Akzentfarbe der App ist selbst
|
||
/// grün – damit sähe "schief" genauso aus wie "eben".
|
||
private var bubbleColor: Color {
|
||
guard let deviation = maxDeviation else { return .secondary }
|
||
if deviation <= LevelState.levelTolerance { return .green }
|
||
if deviation <= 2 { return .orange }
|
||
return .red
|
||
}
|
||
|
||
private var maxDeviation: Double? {
|
||
switch (pitch, roll) {
|
||
case let (p?, r?): return max(abs(p), abs(r))
|
||
case let (p?, nil): return abs(p)
|
||
case let (nil, r?): return abs(r)
|
||
default: return nil
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
GeometryReader { geometry in
|
||
let side = min(geometry.size.width, geometry.size.height)
|
||
let radius = side / 2
|
||
let bubble = side * 0.16
|
||
// Die Blase darf den Rand nicht verlassen, auch bei starker Neigung.
|
||
let travel = radius - bubble / 2 - 4
|
||
|
||
let offsetX = clamped(roll) / range * travel
|
||
let offsetY = clamped(pitch) / range * travel
|
||
|
||
ZStack {
|
||
Circle()
|
||
.fill(Color(.secondarySystemFill))
|
||
Circle()
|
||
.strokeBorder(Color.secondary.opacity(0.35), lineWidth: 1)
|
||
|
||
// Ringe als echter Maßstab: der innere markiert die Toleranz,
|
||
// der mittlere zwei Grad. Ohne Maßstab sagt die Blasenlage
|
||
// nichts darüber, wie weit es noch ist.
|
||
let toleranceRadius = max(LevelState.levelTolerance / range * travel, bubble * 0.6)
|
||
Circle()
|
||
.strokeBorder(isLevel ? Color.green : Color.secondary.opacity(0.5),
|
||
lineWidth: isLevel ? 2 : 1)
|
||
.frame(width: toleranceRadius * 2, height: toleranceRadius * 2)
|
||
Circle()
|
||
.strokeBorder(Color.secondary.opacity(0.3), lineWidth: 1)
|
||
.frame(width: 2.0 / range * travel * 2, height: 2.0 / range * travel * 2)
|
||
|
||
Path { path in
|
||
path.move(to: CGPoint(x: radius - travel, y: radius))
|
||
path.addLine(to: CGPoint(x: radius + travel, y: radius))
|
||
path.move(to: CGPoint(x: radius, y: radius - travel))
|
||
path.addLine(to: CGPoint(x: radius, y: radius + travel))
|
||
}
|
||
.stroke(Color.secondary.opacity(0.25), lineWidth: 1)
|
||
|
||
Circle()
|
||
.fill(bubbleColor)
|
||
.frame(width: bubble, height: bubble)
|
||
// Positiver Pitch heisst: das Heck steht höher, die Blase
|
||
// wandert also nach unten – in der Ansicht nach hinten.
|
||
// Muss zur "Fahrzeug"-Ansicht (VehicleTiltView) passen.
|
||
.offset(x: offsetX, y: offsetY)
|
||
.animation(.spring(duration: 0.35), value: offsetX)
|
||
.animation(.spring(duration: 0.35), value: offsetY)
|
||
.opacity(pitch == nil && roll == nil ? 0.25 : 1)
|
||
}
|
||
.frame(width: side, height: side)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
.aspectRatio(1, contentMode: .fit)
|
||
.accessibilityLabel(accessibilityText)
|
||
}
|
||
|
||
private func clamped(_ value: Double?) -> Double {
|
||
guard let value else { return 0 }
|
||
return min(max(value, -range), range)
|
||
}
|
||
|
||
private var accessibilityText: String {
|
||
guard let pitch, let roll else { return "Keine Messwerte" }
|
||
return String(format: "Längsneigung %.1f Grad, Querneigung %.1f Grad", pitch, roll)
|
||
}
|
||
}
|
||
|
||
/// Anzeige und Kalibrierung des Neigungsmessers.
|
||
struct LevelControls: View {
|
||
let device: ConfiguredDevice
|
||
let state: LevelState
|
||
|
||
@Environment(BluetoothManager.self) private var bluetooth
|
||
@Environment(DeviceStore.self) private var store
|
||
@Environment(LevelActivityManager.self) private var levelActivity
|
||
@State private var showAssistant = false
|
||
@AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble
|
||
|
||
var body: some View {
|
||
Section {
|
||
VStack(spacing: 16) {
|
||
Picker("Darstellung", selection: $displayStyle) {
|
||
ForEach(LevelDisplayStyle.allCases) { style in
|
||
Text(style.title).tag(style)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
|
||
switch displayStyle {
|
||
case .bubble:
|
||
LevelBubble(pitch: state.pitch, roll: state.roll)
|
||
.frame(maxHeight: 220)
|
||
case .vehicle:
|
||
VehicleTiltView(pitch: state.pitch, roll: state.roll)
|
||
}
|
||
|
||
if let instruction = state.instruction {
|
||
Label(instruction,
|
||
systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
|
||
.font(.headline)
|
||
.foregroundStyle(state.isLevel ? Color.green : Color.primary)
|
||
.multilineTextAlignment(.center)
|
||
}
|
||
|
||
HStack(spacing: 24) {
|
||
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
|
||
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 8)
|
||
}
|
||
|
||
Section {
|
||
Button {
|
||
showAssistant = true
|
||
} label: {
|
||
Label {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("Ausrichtungs-Assistent")
|
||
Text("Begleitet das Rangieren")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
} icon: {
|
||
Image(systemName: "car.side.arrowtriangle.up.arrowtriangle.down")
|
||
}
|
||
}
|
||
// Der Assistent lebt von laufenden Messwerten. Ein einmal
|
||
// empfangener Wert genügt nicht: nach einem Verbindungsabbruch
|
||
// bliebe er stehen und die Ansicht sähe eingefroren aus.
|
||
.disabled(!isLive || !state.hasReading)
|
||
// Der Vollbildschirm gehört an den Knopf, nicht an den Abschnitt.
|
||
// SwiftUI zeigt Blätter und Vollbildschirme an einer `Section`
|
||
// nicht zuverlässig an – dasselbe hatte die Kühlbox schon.
|
||
.fullScreenCover(isPresented: $showAssistant) {
|
||
AlignmentAssistantView(device: device, profile: store.activeProfile)
|
||
}
|
||
} footer: {
|
||
if !isLive {
|
||
Text("Der Assistent braucht laufende Messwerte. Der Neigungsmesser "
|
||
+ "ist gerade nicht verbunden.")
|
||
}
|
||
}
|
||
|
||
Section {
|
||
Toggle(isOn: liveActivityBinding) {
|
||
Label {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("Live Activity")
|
||
Text("Sperrbildschirm, Dynamic Island und CarPlay")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
} icon: {
|
||
Image(systemName: "widget.small")
|
||
}
|
||
}
|
||
// Ohne laufende Messwerte gäbe es nichts anzuzeigen – und beim
|
||
// Umschalten hätte die Activity sofort einen veralteten Stand.
|
||
.disabled(!isLive || !state.hasReading)
|
||
} footer: {
|
||
Text(!isLive
|
||
? "Braucht laufende Messwerte. Der Neigungsmesser ist gerade nicht verbunden."
|
||
: "Zeigt die Neigung, solange sie läuft – auch bei gesperrtem Bildschirm. "
|
||
+ "Ist das iPhone mit CarPlay verbunden, erscheint sie automatisch auch dort.")
|
||
}
|
||
|
||
// Einbaulage und Nullpunkt liegen auf einer eigenen Seite. Direkt unter
|
||
// der Libelle verstellte ein Fehlgriff beim Ablesen den Nullpunkt.
|
||
Section {
|
||
NavigationLink {
|
||
LevelSetupView(device: device)
|
||
} label: {
|
||
LabeledContent {
|
||
Text(setupSummary).font(.caption)
|
||
} label: {
|
||
Label("Neigungsmesser einrichten", systemImage: "slider.horizontal.3")
|
||
}
|
||
}
|
||
} footer: {
|
||
Text("Einbaulage des Sensors und Nullpunkt der Anzeige.")
|
||
}
|
||
}
|
||
|
||
private var isLive: Bool { bluetooth.linkStates[device.id] == .live }
|
||
|
||
private var liveActivityBinding: Binding<Bool> {
|
||
Binding(
|
||
get: { levelActivity.isActive(for: device.id) },
|
||
set: { isOn in
|
||
if isOn {
|
||
levelActivity.start(deviceID: device.id, deviceName: device.name, state: state)
|
||
} else {
|
||
levelActivity.end()
|
||
}
|
||
}
|
||
)
|
||
}
|
||
|
||
/// Was auf der Einrichtungsseite zu holen ist – der Nullpunkt zuerst, denn
|
||
/// ohne ihn stimmt die Anzeige nicht.
|
||
private var setupSummary: String {
|
||
state.isKnownUncalibrated ? "Nullpunkt fehlt" : device.sensorOrientation.summary
|
||
}
|
||
|
||
private func reading(_ title: String, _ text: String) -> some View {
|
||
VStack(spacing: 2) {
|
||
Text(text)
|
||
.font(.title2.weight(.semibold).monospacedDigit())
|
||
Text(title)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|