first commit

This commit is contained in:
fototeddy
2026-09-04 21:30:51 +02:00
commit da64b1b6bd
160 changed files with 20664 additions and 0 deletions
@@ -0,0 +1,115 @@
import SwiftUI
import WatchKit
/// Der Ausrichtungs-Assistent am Handgelenk der eigentliche Grund für die
/// Uhr: Beim Rangieren hat man das iPhone nicht in der Hand, aber den Arm am
/// Lenkrad. Ein Blick nach unten genügt, und die Rückmeldung steht eben kommt
/// zusätzlich als Vibration, ganz ohne Blick.
///
/// Gerechnet wird mit demselben `AlignmentAssistant` wie am iPhone: kein
/// Ortsbezug, nur der zeitliche Verlauf der Neigung. Was besser und was
/// schlechter wird, steckt vollständig darin.
struct WatchAlignView: View {
@Environment(PhoneLink.self) private var link
@Environment(WatchLevelRadio.self) private var radio
@State private var assistant = AlignmentAssistant()
/// Damit die Vibration einmal kommt und nicht im Sekundentakt.
@State private var hasAnnouncedTarget = false
private var reading: WatchLevelReading { WatchLevelReading(radio: radio, phone: link) }
private var state: LevelState? { reading.hasReading ? reading.state : nil }
var body: some View {
// Ohne ScrollView: Beim Rangieren schaut man kurz hin, da darf nichts
// ausserhalb des Bildes liegen. Alles ist deshalb so bemessen, dass es
// auch auf der kleinsten Uhr auf einen Blick passt.
VStack(spacing: 3) {
if let current = assistant.current {
Text(String(format: "%.1f°", current.deviation))
.font(.system(size: 30, weight: .semibold, design: .rounded))
.monospacedDigit()
.foregroundStyle(assistant.hasReachedTarget ? .green : .primary)
.contentTransition(.numericText())
} else {
ProgressView()
.padding(.vertical, 6)
}
Label(assistant.trend.text, systemImage: assistant.trend.symbol)
.font(.caption2.weight(.medium))
.foregroundStyle(trendColor)
Text(assistant.advice)
.font(.system(size: 11))
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.lineLimit(2)
.minimumScaleFactor(0.8)
if let state {
HStack(spacing: 8) {
WatchBubble(pitch: state.pitch, roll: state.roll)
.frame(width: 40, height: 40)
VStack(alignment: .leading, spacing: 1) {
Text(String(format: "Längs %.1f°", state.pitch ?? 0))
Text(String(format: "Quer %.1f°", state.roll ?? 0))
}
.font(.system(size: 11))
.monospacedDigit()
.foregroundStyle(.secondary)
}
}
Button {
assistant.reset()
hasAnnouncedTarget = false
} label: {
Label("Von vorn", systemImage: "arrow.counterclockwise")
.font(.system(size: 10))
}
.buttonStyle(.borderless)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal, 2)
.navigationTitle("Ausrichten")
.onChange(of: tilt, initial: true) { _, tilt in
guard let tilt else { return }
assistant.add(pitch: tilt.pitch, roll: tilt.roll)
announceIfLevel()
}
}
/// Ein Wertepaar als Auslöser: gerechnet wird nur, wenn sich die Neigung
/// wirklich ändert nicht bei jeder Aktualisierung der Anzeige.
private var tilt: Tilt? {
guard let state, let pitch = state.pitch, let roll = state.roll else { return nil }
return Tilt(pitch: pitch, roll: roll)
}
private struct Tilt: Equatable {
let pitch: Double
let roll: Double
}
private var trendColor: Color {
switch assistant.trend {
case .improving: return .green
case .worsening: return .orange
default: return .secondary
}
}
/// Die Vibration ist der Punkt: Sie sagt anhalten, ohne dass jemand
/// hinschauen muss. Erneut gemeldet wird erst, wenn das Fahrzeug die
/// Toleranz zwischendurch wieder verlassen hat.
private func announceIfLevel() {
if assistant.hasReachedTarget {
guard !hasAnnouncedTarget else { return }
hasAnnouncedTarget = true
WKInterfaceDevice.current().play(.success)
} else {
hasAnnouncedTarget = false
}
}
}
@@ -0,0 +1,77 @@
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 oben 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)
}
}
@@ -0,0 +1,69 @@
import SwiftUI
/// Alle Werte eines Geräts. Am iPhone stehen hier zusätzlich Verlauf und
/// Diagnose auf der Uhr wäre beides unlesbar und ist deshalb weggelassen.
struct WatchDeviceDetailView: View {
let deviceID: UUID
@Environment(PhoneLink.self) private var link
private var device: WatchDevice? {
link.payload?.devices.first { $0.id == deviceID }
}
var body: some View {
List {
if let device {
/*if let fridge = device.fridge {
WatchFridgeControls(deviceID: device.id, fridge: fridge)
}*/
Section {
ForEach(device.snapshot?.metrics.filter { $0.value != nil } ?? []) { metric in
LabeledContent(metric.label) {
Text(metric.formattedWithUnit)
.monospacedDigit()
}
.font(.footnote)
}
} header: {
Text("Messwerte")
}
Section {
if let fault = device.snapshot?.fault {
Label(fault, systemImage: "exclamationmark.triangle.fill")
.font(.caption2)
.foregroundStyle(.red)
}
if let state = device.snapshot?.state {
Text(state).font(.caption2)
}
ForEach(device.snapshot?.offReasons ?? [], id: \.self) { reason in
Text(reason)
.font(.caption2)
.foregroundStyle(.secondary)
}
Label(device.link.label, systemImage: "antenna.radiowaves.left.and.right")
.font(.caption2)
.foregroundStyle(.secondary)
}
} else {
Text("Gerät ist nicht mehr im Fahrzeugprofil.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationTitle(device?.name ?? "Gerät")
// Die Kühlbox wird nur verbunden, solange jemand sie ansieht. Das gilt
// auch von hier aus das iPhone verbindet auf Zuruf.
.onAppear {
//guard device?.fridge != nil else { return }
//link.send(.fridgeSession(device: deviceID, wanted: true))
}
.onDisappear {
// guard device?.fridge != nil else { return }
//link.send(.fridgeSession(device: deviceID, wanted: false))
}
}
}
@@ -0,0 +1,230 @@
import SwiftUI
/// Nivellierung auf der Uhr, aufgeteilt auf drei Seiten zum Wischen.
///
/// Auf einem Bildschirm dieser Grösse ist Wischen besser als Scrollen: Beim
/// Rangieren trifft man einen Wisch auch ohne hinzusehen, eine Scrollposition
/// dagegen nicht und was gerade wichtig ist, hängt vom Arbeitsschritt ab.
///
/// 1. **Neigung** wie das Fahrzeug steht, als Fahrzeugansicht oder Libelle.
/// 2. **Keile** wie hoch jedes Rad unterlegt werden muss.
/// 3. **Ausrichten** der Assistent fürs Rangieren, mit Vibration.
///
/// Die Werte kommen bevorzugt **direkt vom Sensor** (`WatchLevelRadio`); nur
/// wenn der gerade nicht erreichbar ist, tritt der zuletzt über das iPhone
/// gemeldete Stand an seine Stelle. Woher sie stammen, steht auf der ersten
/// Seite ein alter Wert sieht sonst aus wie ein stillstehendes Fahrzeug.
///
/// Kalibriert wird hier nichts. Das gehört einmalig ans iPhone, mit ebenem
/// Fahrzeug und Ruhe dafür; auf der Uhr wäre der Knopf nur eine Gelegenheit,
/// die Nullage aus Versehen zu verstellen.
struct WatchLevelView: View {
@State private var page = Page.reading
enum Page: Hashable {
case reading, wedges, align
}
var body: some View {
TabView(selection: $page) {
LevelReadingPage()
.tag(Page.reading)
WedgePage()
.tag(Page.wedges)
WatchAlignView()
.tag(Page.align)
}
.tabViewStyle(.page)
}
}
/// Erste Seite: wie das Fahrzeug steht.
struct LevelReadingPage: View {
@Environment(PhoneLink.self) private var link
@Environment(WatchLevelRadio.self) private var radio
@AppStorage("levelDisplayStyle") private var showsVehicle = true
private var reading: WatchLevelReading { WatchLevelReading(radio: radio, phone: link) }
var body: some View {
ScrollView {
VStack(spacing: 8) {
if showsVehicle {
WatchVehicleView(pitch: reading.state.pitch, roll: reading.state.roll)
} else {
WatchBubble(pitch: reading.state.pitch, roll: reading.state.roll)
.frame(maxWidth: 110)
HStack(spacing: 14) {
angle("Längs", reading.state.pitch)
angle("Quer", reading.state.roll)
}
}
Text(reading.state.instruction ?? "Keine Werte")
.font(.footnote.weight(.medium))
.multilineTextAlignment(.center)
.foregroundStyle(reading.state.isLevel ? .green : .primary)
Button {
showsVehicle.toggle()
} label: {
Label(showsVehicle ? "Libelle" : "Fahrzeug",
systemImage: showsVehicle ? "circle.circle" : "box.truck")
}
.font(.caption2)
SourceNote(reading: reading)
}
.padding(.horizontal, 4)
}
.navigationTitle("Neigung")
}
private func angle(_ title: String, _ value: Double?) -> some View {
VStack(spacing: 0) {
Text(value.map { String(format: "%.1f°", $0) } ?? "")
.font(.title3.weight(.semibold))
.monospacedDigit()
Text(title)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
/// Zweite Seite: die Keilhöhen, als Draufsicht auf die vier Räder.
struct WedgePage: View {
@Environment(PhoneLink.self) private var link
@Environment(WatchLevelRadio.self) private var radio
private var reading: WatchLevelReading { WatchLevelReading(radio: radio, phone: link) }
var body: some View {
ScrollView {
VStack(spacing: 8) {
if let lift, !lift.isNegligible {
WheelLiftPlan(lift: lift, isCompact: true)
Text("Zentimeter unter das jeweilige Rad. Das höchste bleibt liegen.")
.font(.system(size: 10))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
} else if hasMeasurements {
Label("Keine Keile nötig", systemImage: "checkmark.circle.fill")
.font(.footnote)
.foregroundStyle(.green)
.padding(.top, 20)
} else {
// Ohne Fahrzeugmasse lässt sich nichts rechnen. Das gehört
// gesagt, sonst wirkt die Seite kaputt.
Label("Fahrzeugmasse fehlen", systemImage: "ruler")
.font(.footnote.weight(.medium))
Text("Spurweite und Radstand am iPhone im Fahrzeugprofil "
+ "hinterlegen, dann steht hier die Keilhöhe je Rad.")
.font(.system(size: 10))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
.padding(.horizontal, 4)
}
.navigationTitle("Keile")
}
private var hasMeasurements: Bool {
link.payload?.profile.trackWidth != nil && link.payload?.profile.wheelbase != nil
}
/// Wie hoch jedes Rad unterlegt werden muss reine Geometrie, und nur zu
/// haben, wenn im Fahrzeugprofil Spurweite und Radstand stehen.
private var lift: LevelingLift? {
guard let profile = link.payload?.profile,
let width = profile.trackWidth, let base = profile.wheelbase,
let pitch = reading.state.pitch, let roll = reading.state.roll
else { return nil }
return LevelingLift.compute(pitch: pitch, roll: roll,
trackWidth: width, wheelbase: base)
}
}
/// Woher die angezeigte Neigung kommt und wie alt sie ist.
struct SourceNote: View {
let reading: WatchLevelReading
var body: some View {
TimelineView(.periodic(from: .now, by: 1)) { context in
let age = reading.age(now: context.date)
Label {
Text(text(age: age))
} icon: {
Image(systemName: reading.isDirect
? "antenna.radiowaves.left.and.right" : "iphone.gen3")
}
.font(.system(size: 10))
.foregroundStyle(age > 5 ? .orange : .secondary)
}
}
private func text(age: TimeInterval) -> String {
guard reading.hasReading else { return reading.statusText }
let source = reading.isDirect ? "Sensor" : "iPhone"
if age < 3 { return "\(source), eben gemessen" }
// Steht der Wert, gehört der Grund dazu und nicht nur das Alter.
if age < 90 { return String(format: "%@, vor %.0f s · %@", source, age, reading.statusText) }
return String(format: "%@, vor %.0f min · %@", source, age / 60, reading.statusText)
}
}
/// Die angezeigte Neigung samt Herkunft.
///
/// Die eigene Verbindung hat Vorrang, solange sie **frisch** ist. Das solange
/// ist der Punkt: Bleibt der Sensor stehen, ohne die Verbindung zu trennen,
/// würde ein einmal gelesener Wert sonst für immer stehenbleiben und eine
/// eingefrorene Anzeige ist beim Ausrichten schlimmer als gar keine. Dann tritt
/// der Stand des iPhones an seine Stelle, und wenn auch der fehlt, bleibt der
/// alte Wert stehen, aber mit seinem Alter daneben.
struct WatchLevelReading {
let state: LevelState
let isDirect: Bool
let measuredAt: Date?
let statusText: String
/// Ab hier gilt ein direkt gemessener Wert als überholt. Gefragt wird
/// zweimal je Sekunde ab; drei Sekunden Stille sind schon viel.
static let directLifetime: TimeInterval = 3
init(radio: WatchLevelRadio, phone: PhoneLink, now: Date = Date()) {
let directAge = radio.updatedAt.map { now.timeIntervalSince($0) }
let hasFreshDirect = radio.state.hasReading
&& (directAge ?? .infinity) <= Self.directLifetime
let relayed = phone.payload?.levelDevice
if hasFreshDirect {
state = radio.state
isDirect = true
measuredAt = radio.updatedAt
statusText = radio.statusText
} else if let relayed, let level = relayed.level {
state = level
isDirect = false
measuredAt = relayed.snapshot?.timestamp ?? phone.payload?.generatedAt
statusText = relayed.link.label
} else if radio.state.hasReading {
state = radio.state
isDirect = true
measuredAt = radio.updatedAt
statusText = radio.statusText
} else {
state = LevelState()
isDirect = false
measuredAt = nil
statusText = radio.statusText
}
}
var hasReading: Bool { state.hasReading }
func age(now: Date = Date()) -> TimeInterval {
guard let measuredAt else { return 0 }
return now.timeIntervalSince(measuredAt)
}
}
@@ -0,0 +1,185 @@
import SwiftUI
/// Der Einstieg: das Wichtigste untereinander, in der Reihenfolge, in der man
/// im Wohnmobil danach schaut.
struct WatchRootView: View {
@Environment(PhoneLink.self) private var link
@Environment(WatchLevelRadio.self) private var radio
/// Der Weg durch die Ansichten liegt hier, damit ihn auch die Komplikation
/// setzen kann: Ein Tipp aufs Zifferblatt springt direkt zur Nivellierung,
/// statt in der Geräteliste zu landen.
@State private var path: [WatchRoute] = []
var body: some View {
NavigationStack(path: $path) {
content
.navigationTitle(link.payload?.profile.name ?? "Camper")
.navigationDestination(for: WatchRoute.self) { route in
switch route {
case .level: WatchLevelView()
case .device(let id): WatchDeviceDetailView(deviceID: id)
}
}
}
.onOpenURL { url in
guard WatchDeepLink.isLevel(url) else { return }
// Nicht anhängen, sondern ersetzen: Wer vom Zifferblatt kommt, will
// die Nivellierung sehen und nicht einen Stapel von vorhin.
path = [.level]
}
}
private var content: some View {
List {
// Die Nivellierung steht immer oben und immer da: Sie kommt vom
// Sensor selbst und braucht das iPhone nicht.
Section {
NavigationLink(value: WatchRoute.level) {
LevelSummaryRow(reading: WatchLevelReading(radio: radio, phone: link))
}
}
if let payload = link.payload {
Section {
ForEach(payload.devices.filter { $0.role != .leveling }) { device in
NavigationLink(value: WatchRoute.device(device.id)) {
DeviceRow(device: device)
}
}
}
Section {
FreshnessNote(payload: payload, receivedAt: link.receivedAt)
}
} else {
Section {
waiting
} footer: {
Text("Batterie, Solar und Kühlbox kommen vom iPhone. Die "
+ "Nivellierung oben nicht die geht direkt.")
.font(.system(size: 10))
}
}
}
}
private var waiting: some View {
VStack(spacing: 4) {
Label("Warte auf das iPhone", systemImage: "iphone.gen3.radiowaves.left.and.right")
.font(.footnote.weight(.medium))
Text("Dort muss VanControl Pro geöffnet sein.")
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
/// Wohin die Uhr springen kann. Als eigener Typ, weil der Weg von aussen
/// gesetzt wird von der Komplikation.
enum WatchRoute: Hashable {
case level
case device(UUID)
}
/// Eine Gerätezeile: Name klein, Hauptwert gross auf dem kleinen Bildschirm
/// zählt nur der eine Wert, wegen dem man den Arm hebt.
struct DeviceRow: View {
let device: WatchDevice
var body: some View {
HStack(spacing: 8) {
Image(systemName: device.role.symbol)
.foregroundStyle(.tint)
.frame(width: 20)
VStack(alignment: .leading, spacing: 1) {
Text(device.name)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
if let metric = device.snapshot?.primaryMetric, metric.value != nil {
Text(metric.formattedWithUnit)
.font(.title3.weight(.semibold))
.monospacedDigit()
.foregroundStyle(device.isLive ? .primary : .secondary)
} else {
Text(device.link.label)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
Spacer(minLength: 0)
Circle()
.fill(device.statusColor)
.frame(width: 7, height: 7)
}
}
}
/// Die Nivellierung bekommt eine eigene, grössere Zeile: sie ist der Grund,
/// aus dem man die Uhr im Fahrzeug überhaupt anschaut.
struct LevelSummaryRow: View {
let reading: WatchLevelReading
var body: some View {
HStack(spacing: 10) {
WatchBubble(pitch: reading.state.pitch, roll: reading.state.roll)
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text("Nivellierung")
.font(.caption2)
.foregroundStyle(.secondary)
// Ohne Messwerte steht hier, woran es liegt, statt eines
// nichtssagenden Strichs.
Text(reading.hasReading
? (reading.state.instruction ?? "Keine Werte")
: reading.statusText)
.font(.footnote.weight(.medium))
.lineLimit(2)
}
Spacer(minLength: 0)
}
.padding(.vertical, 2)
}
}
/// Wie alt der Stand ist auf der Uhr die wichtigste Nebenangabe. Alles hier
/// ist weitergereicht; eine abgerissene Strecke zum iPhone sieht sonst genauso
/// aus wie ein Fahrzeug, an dem sich nichts tut.
struct FreshnessNote: View {
let payload: WatchPayload
let receivedAt: Date?
var body: some View {
TimelineView(.periodic(from: .now, by: 1)) { context in
let age = context.date.timeIntervalSince(receivedAt ?? payload.generatedAt)
Label {
Text(text(age: age))
.font(.caption2)
} icon: {
Image(systemName: age > 10 ? "exclamationmark.triangle.fill" : "iphone.gen3")
}
.foregroundStyle(age > 10 ? .orange : .secondary)
}
}
private func text(age: TimeInterval) -> String {
if !payload.isRadioReady { return payload.radioStatus }
if age < 3 { return "Stand: eben" }
if age < 90 { return String(format: "Stand: vor %.0f s", age) }
return String(format: "Stand: vor %.0f min iPhone erreichbar?", age / 60)
}
}
extension WatchDevice {
/// Grün heisst: der Wert ist frisch und kommt vom Gerät selbst.
var statusColor: Color {
switch link {
case .live: return (snapshot?.isStale ?? true) ? .orange : .green
case .needsKey: return .orange
case .failed: return .red
default: return .gray
}
}
}
@@ -0,0 +1,67 @@
import SwiftUI
/// Die Neigung am Fahrzeug selbst, wie am iPhone nur auf Uhrgrösse gebracht.
///
/// Zwei Ansichten, jede um ihre Achse gekippt: die Seitenansicht für längs
/// (Front links, Heck rechts) und die Heckansicht für quer. Die Heckansicht
/// teilt die Blickrichtung des Fahrers, links im Bild ist also links am
/// Fahrzeug.
struct WatchVehicleView: View {
let pitch: Double?
let roll: Double?
/// Wie am iPhone dreifach überhöht zwei Grad wären sonst kaum zu sehen.
/// Die angeschriebenen Gradzahlen bleiben echt.
var body: some View {
VStack(spacing: 8) {
panel(image: "VehicleSide", angle: pitch, title: "Längs",
lower: "Front", upper: "Heck", aspect: VehicleTilt.sideAspect)
panel(image: "VehicleRear", angle: roll, title: "Quer",
lower: "links", upper: "rechts", aspect: VehicleTilt.rearAspect)
}
}
private func panel(image: String,
angle: Double?,
title: String,
lower: String,
upper: String,
aspect: Double) -> some View {
VStack(spacing: 2) {
HStack {
Text(title)
Spacer()
Text(angle.map { String(format: "%.1f°", $0) } ?? "")
.monospacedDigit()
.foregroundStyle(VehicleTilt.colour(for: angle))
}
.font(.caption2)
ZStack {
// Die Waagerechte als Bezug ohne sie ist eine kleine Neigung
// nicht einzuschätzen.
Rectangle()
.fill(Color.secondary.opacity(0.35))
.frame(height: 1)
Image(image)
.resizable()
.scaledToFit()
.foregroundStyle(VehicleTilt.colour(for: angle))
.aspectRatio(aspect, contentMode: .fit)
.rotationEffect(.degrees(-(angle ?? 0) * VehicleTilt.exaggeration))
.animation(.spring(duration: 0.4), value: angle)
.opacity(angle == nil ? 0.3 : 1)
}
.frame(height: 52)
HStack {
Text(lower)
Spacer()
Text(upper)
}
.font(.system(size: 9))
.foregroundStyle(.secondary)
}
}
}