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) } }