Files
Camper-Monitor/CamperMonitorWatch/Views/WatchLevelView.swift
T
BiasFandClaude Opus 5 8aa8658b5c Uhr: Nivellierung auf drei Seiten, Assistent passt ohne Scrollen
Alles auf einem Bildschirm hiess scrollen, und auf dieser Grösse ist das
der falsche Handgriff: Einen Wisch trifft man beim Rangieren auch ohne
hinzusehen, eine Scrollposition nicht. Was gerade zählt, hängt ausserdem
vom Arbeitsschritt ab.

Jetzt drei Seiten zum Wischen:

* Neigung – Fahrzeugansicht oder Libelle, was zu tun ist, Herkunft der
  Werte
* Keile – die Höhe je Rad. Fehlen Spurweite und Radstand, steht das dort,
  statt dass die Seite leer wirkt.
* Ausrichten – der Assistent, vorher hinter einem Knopf

Der Assistent ist dabei enger gesetzt: kleinere Zahl, zwei Zeilen Rat mit
Skalierung, kleinere Libelle, und kein ScrollView mehr. Beim Rangieren
schaut man kurz hin, da darf nichts ausserhalb des Bildes liegen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:59:52 +02:00

231 lines
8.9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}