Files
Camper-Monitor/CamperMonitorWatch/Views/WatchRootView.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

186 lines
6.7 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
/// 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
}
}
}