forked from fritob/Camper-Monitor
Die Uhr zeigte in der Fahrzeug-Ansicht bislang immer Vanster fest verdrahtet, unabhängig von der Auswahl am iPhone – das galt schon vor dieser Session, nicht erst seit dem Solar-Merge. WatchDevice trägt jetzt vehicleGraphicStyle (nur beim Neigungsmesser belegt, wie orientation), von PhoneWatchLink beim Aufbau der Nutzlast gefüllt. WatchVehicleView nimmt den Stil als Parameter statt der fest verdrahteten Vanster-Bildnamen, WatchLevelView reicht ihn aus dem zuletzt vom iPhone übertragenen Stand durch. California-Bilder dafür zusätzlich in den Watch-eigenen Asset-Katalog kopiert (die Uhr hat einen eigenen, vom iPhone getrennten Katalog). Ausgewählt wird der Stil weiterhin nur am iPhone (Neigungsmesser einrichten → Fahrzeuggrafik). Persistenz war dafür schon vorhanden: vehicleGraphicStyle ist ein normales, codiertes Feld auf ConfiguredDevice ohne eigene encode(to:)-Überschreibung – wird also schon beim Speichern mitgeschrieben. Nebenbei: Xcode hat beim Bearbeiten den Build-Zähler auf 2 gesetzt und LSApplicationCategoryType (Dienstprogramme) ergänzt – unverändert übernommen. Build (App + Watch) und Tests grün. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
232 lines
9.0 KiB
Swift
232 lines
9.0 KiB
Swift
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,
|
||
style: link.payload?.levelDevice?.vehicleGraphicStyle ?? .vanster)
|
||
} 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)
|
||
}
|
||
}
|