forked from fritob/Camper-Monitor
Bisher standen dort zwei Angaben nebeneinander, etwa "rechts 8 cm" und "vorne 4 cm". Das liest sich wie zwei Aufgaben, ist aber eine: Unter "rechts" liegen zwei Räder, unter "vorne" auch, und eines davon ist dasselbe. Ausführen lässt sich das so nicht. Gerechnet wird jetzt über alle vier Aufstandspunkte auf einmal. Aus Spurweite, Radstand und den beiden Winkeln ergeben sich deren Höhen; angehoben wird auf das höchststehende Rad, das liegen bleibt. Aus dem Beispiel oben werden damit 12 cm vorne rechts, 8 cm hinten rechts, 4 cm vorne links und nichts hinten links – dieselbe Geometrie, nur richtig zusammengesetzt. Angezeigt wird das als Draufsicht auf das Fahrzeug (WheelLiftPlan), auf iPhone und Uhr dieselbe Ansicht, auf der Uhr kompakt. LevelingWedge entfällt. Abgesichert in run-tests.sh: reine Querneigung, reine Längsneigung, beides zusammen (16,1 / 9,2 / 7,0 / 0 cm), gespiegelte Vorzeichen und die Fälle ohne Fahrzeugmasse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
171 lines
6.5 KiB
Swift
171 lines
6.5 KiB
Swift
import SwiftUI
|
||
|
||
/// Nivellierung auf der Uhr.
|
||
///
|
||
/// 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 unten – 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 {
|
||
@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)
|
||
|
||
if let lift, !lift.isNegligible {
|
||
WheelLiftPlan(lift: lift, isCompact: true)
|
||
.padding(.top, 2)
|
||
}
|
||
|
||
NavigationLink {
|
||
WatchAlignView()
|
||
} label: {
|
||
Label("Ausrichten", systemImage: "arrow.left.and.right")
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
|
||
Button {
|
||
showsVehicle.toggle()
|
||
} label: {
|
||
Label(showsVehicle ? "Libelle" : "Fahrzeug",
|
||
systemImage: showsVehicle ? "circle.circle" : "box.truck")
|
||
}
|
||
.font(.caption2)
|
||
|
||
SourceNote(reading: reading)
|
||
}
|
||
.padding(.horizontal, 4)
|
||
}
|
||
.navigationTitle("Nivellierung")
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
}
|