- 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>
78 lines
3.1 KiB
Swift
78 lines
3.1 KiB
Swift
import SwiftUI
|
||
|
||
/// Libelle für die Uhr: eine Blase, die dorthin wandert, wo das Fahrzeug
|
||
/// **höher** steht – wie bei einer echten Wasserwaage.
|
||
///
|
||
/// Bewusst schlichter als am iPhone: Auf 40 mm Bildschirm ist der innere Ring
|
||
/// (die Toleranz) die einzige Hilfslinie, die sich noch ablesen lässt.
|
||
struct WatchBubble: View {
|
||
let pitch: Double?
|
||
let roll: Double?
|
||
/// Bis zu welcher Neigung die Anzeige ausschlägt.
|
||
var range: Double = 4
|
||
|
||
var body: some View {
|
||
GeometryReader { geometry in
|
||
let side = min(geometry.size.width, geometry.size.height)
|
||
let radius = side / 2
|
||
let bubble = side * 0.22
|
||
let travel = radius - bubble / 2 - 2
|
||
let tolerance = max(LevelState.levelTolerance / range * travel, bubble * 0.55)
|
||
|
||
ZStack {
|
||
Circle().fill(Color.gray.opacity(0.25))
|
||
Circle().strokeBorder(Color.gray.opacity(0.5), lineWidth: 1)
|
||
Circle()
|
||
.strokeBorder(isLevel ? Color.green : Color.gray.opacity(0.6),
|
||
lineWidth: isLevel ? 2 : 1)
|
||
.frame(width: tolerance * 2, height: tolerance * 2)
|
||
|
||
Circle()
|
||
.fill(color)
|
||
.frame(width: bubble, height: bubble)
|
||
// Positiver Pitch heisst: das Heck steht höher, die Blase
|
||
// wandert nach unten – in der Ansicht also nach hinten.
|
||
.offset(x: clamped(roll) / range * travel,
|
||
y: clamped(pitch) / range * travel)
|
||
.animation(.spring(duration: 0.3), value: pitch)
|
||
.animation(.spring(duration: 0.3), value: roll)
|
||
.opacity(pitch == nil && roll == nil ? 0.25 : 1)
|
||
}
|
||
.frame(width: side, height: side)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
.aspectRatio(1, contentMode: .fit)
|
||
.accessibilityLabel(accessibilityText)
|
||
}
|
||
|
||
/// Hält die Blase im Ring, auch wenn das Fahrzeug stark schräg steht.
|
||
private func clamped(_ value: Double?) -> Double {
|
||
guard let value else { return 0 }
|
||
return min(max(value, -range), range)
|
||
}
|
||
|
||
private var deviation: 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
|
||
}
|
||
}
|
||
|
||
private var isLevel: Bool { (deviation ?? .infinity) <= LevelState.levelTolerance }
|
||
|
||
/// Grün nur, wenn es wirklich eben ist – die Akzentfarbe der App ist selbst
|
||
/// grün, sonst sähe schief aus wie eben.
|
||
private var color: Color {
|
||
guard let deviation else { return .gray }
|
||
if deviation <= LevelState.levelTolerance { return .green }
|
||
return deviation <= 2 ? .orange : .red
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|