first commit
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import SwiftUI
|
||||
import WatchKit
|
||||
|
||||
/// Der Ausrichtungs-Assistent am Handgelenk – der eigentliche Grund für die
|
||||
/// Uhr: Beim Rangieren hat man das iPhone nicht in der Hand, aber den Arm am
|
||||
/// Lenkrad. Ein Blick nach unten genügt, und die Rückmeldung „steht eben“ kommt
|
||||
/// zusätzlich als Vibration, ganz ohne Blick.
|
||||
///
|
||||
/// Gerechnet wird mit demselben `AlignmentAssistant` wie am iPhone: kein
|
||||
/// Ortsbezug, nur der zeitliche Verlauf der Neigung. Was besser und was
|
||||
/// schlechter wird, steckt vollständig darin.
|
||||
struct WatchAlignView: View {
|
||||
@Environment(PhoneLink.self) private var link
|
||||
@Environment(WatchLevelRadio.self) private var radio
|
||||
@State private var assistant = AlignmentAssistant()
|
||||
/// Damit die Vibration einmal kommt und nicht im Sekundentakt.
|
||||
@State private var hasAnnouncedTarget = false
|
||||
|
||||
private var reading: WatchLevelReading { WatchLevelReading(radio: radio, phone: link) }
|
||||
private var state: LevelState? { reading.hasReading ? reading.state : nil }
|
||||
|
||||
var body: some View {
|
||||
// Ohne ScrollView: Beim Rangieren schaut man kurz hin, da darf nichts
|
||||
// ausserhalb des Bildes liegen. Alles ist deshalb so bemessen, dass es
|
||||
// auch auf der kleinsten Uhr auf einen Blick passt.
|
||||
VStack(spacing: 3) {
|
||||
if let current = assistant.current {
|
||||
Text(String(format: "%.1f°", current.deviation))
|
||||
.font(.system(size: 30, weight: .semibold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(assistant.hasReachedTarget ? .green : .primary)
|
||||
.contentTransition(.numericText())
|
||||
} else {
|
||||
ProgressView()
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
Label(assistant.trend.text, systemImage: assistant.trend.symbol)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(trendColor)
|
||||
|
||||
Text(assistant.advice)
|
||||
.font(.system(size: 11))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.8)
|
||||
|
||||
if let state {
|
||||
HStack(spacing: 8) {
|
||||
WatchBubble(pitch: state.pitch, roll: state.roll)
|
||||
.frame(width: 40, height: 40)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(String(format: "Längs %.1f°", state.pitch ?? 0))
|
||||
Text(String(format: "Quer %.1f°", state.roll ?? 0))
|
||||
}
|
||||
.font(.system(size: 11))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
assistant.reset()
|
||||
hasAnnouncedTarget = false
|
||||
} label: {
|
||||
Label("Von vorn", systemImage: "arrow.counterclockwise")
|
||||
.font(.system(size: 10))
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(.horizontal, 2)
|
||||
.navigationTitle("Ausrichten")
|
||||
.onChange(of: tilt, initial: true) { _, tilt in
|
||||
guard let tilt else { return }
|
||||
assistant.add(pitch: tilt.pitch, roll: tilt.roll)
|
||||
announceIfLevel()
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Wertepaar als Auslöser: gerechnet wird nur, wenn sich die Neigung
|
||||
/// wirklich ändert – nicht bei jeder Aktualisierung der Anzeige.
|
||||
private var tilt: Tilt? {
|
||||
guard let state, let pitch = state.pitch, let roll = state.roll else { return nil }
|
||||
return Tilt(pitch: pitch, roll: roll)
|
||||
}
|
||||
|
||||
private struct Tilt: Equatable {
|
||||
let pitch: Double
|
||||
let roll: Double
|
||||
}
|
||||
|
||||
private var trendColor: Color {
|
||||
switch assistant.trend {
|
||||
case .improving: return .green
|
||||
case .worsening: return .orange
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Vibration ist der Punkt: Sie sagt „anhalten“, ohne dass jemand
|
||||
/// hinschauen muss. Erneut gemeldet wird erst, wenn das Fahrzeug die
|
||||
/// Toleranz zwischendurch wieder verlassen hat.
|
||||
private func announceIfLevel() {
|
||||
if assistant.hasReachedTarget {
|
||||
guard !hasAnnouncedTarget else { return }
|
||||
hasAnnouncedTarget = true
|
||||
WKInterfaceDevice.current().play(.success)
|
||||
} else {
|
||||
hasAnnouncedTarget = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Libelle für die Uhr: eine Blase, die dorthin wandert, wo das Fahrzeug
|
||||
/// **höher** steht – wie bei einer echten Wasserwaage.
|
||||
///
|
||||
/// Bewusst schlichter als am iPhone: Auf 40 mm Bildschirm ist der innere Ring
|
||||
/// (die Toleranz) die einzige Hilfslinie, die sich noch ablesen lässt.
|
||||
struct WatchBubble: View {
|
||||
let pitch: Double?
|
||||
let roll: Double?
|
||||
/// Bis zu welcher Neigung die Anzeige ausschlägt.
|
||||
var range: Double = 4
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let side = min(geometry.size.width, geometry.size.height)
|
||||
let radius = side / 2
|
||||
let bubble = side * 0.22
|
||||
let travel = radius - bubble / 2 - 2
|
||||
let tolerance = max(LevelState.levelTolerance / range * travel, bubble * 0.55)
|
||||
|
||||
ZStack {
|
||||
Circle().fill(Color.gray.opacity(0.25))
|
||||
Circle().strokeBorder(Color.gray.opacity(0.5), lineWidth: 1)
|
||||
Circle()
|
||||
.strokeBorder(isLevel ? Color.green : Color.gray.opacity(0.6),
|
||||
lineWidth: isLevel ? 2 : 1)
|
||||
.frame(width: tolerance * 2, height: tolerance * 2)
|
||||
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: bubble, height: bubble)
|
||||
// Positiver Pitch heisst: das Heck steht höher, die Blase
|
||||
// wandert nach oben – in der Ansicht also nach hinten.
|
||||
.offset(x: clamped(roll) / range * travel,
|
||||
y: -clamped(pitch) / range * travel)
|
||||
.animation(.spring(duration: 0.3), value: pitch)
|
||||
.animation(.spring(duration: 0.3), value: roll)
|
||||
.opacity(pitch == nil && roll == nil ? 0.25 : 1)
|
||||
}
|
||||
.frame(width: side, height: side)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.accessibilityLabel(accessibilityText)
|
||||
}
|
||||
|
||||
/// Hält die Blase im Ring, auch wenn das Fahrzeug stark schräg steht.
|
||||
private func clamped(_ value: Double?) -> Double {
|
||||
guard let value else { return 0 }
|
||||
return min(max(value, -range), range)
|
||||
}
|
||||
|
||||
private var deviation: Double? {
|
||||
switch (pitch, roll) {
|
||||
case let (p?, r?): return max(abs(p), abs(r))
|
||||
case let (p?, nil): return abs(p)
|
||||
case let (nil, r?): return abs(r)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private var isLevel: Bool { (deviation ?? .infinity) <= LevelState.levelTolerance }
|
||||
|
||||
/// Grün nur, wenn es wirklich eben ist – die Akzentfarbe der App ist selbst
|
||||
/// grün, sonst sähe schief aus wie eben.
|
||||
private var color: Color {
|
||||
guard let deviation else { return .gray }
|
||||
if deviation <= LevelState.levelTolerance { return .green }
|
||||
return deviation <= 2 ? .orange : .red
|
||||
}
|
||||
|
||||
private var accessibilityText: String {
|
||||
guard let pitch, let roll else { return "Keine Messwerte" }
|
||||
return String(format: "Längsneigung %.1f Grad, Querneigung %.1f Grad", pitch, roll)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Alle Werte eines Geräts. Am iPhone stehen hier zusätzlich Verlauf und
|
||||
/// Diagnose – auf der Uhr wäre beides unlesbar und ist deshalb weggelassen.
|
||||
struct WatchDeviceDetailView: View {
|
||||
let deviceID: UUID
|
||||
|
||||
@Environment(PhoneLink.self) private var link
|
||||
|
||||
private var device: WatchDevice? {
|
||||
link.payload?.devices.first { $0.id == deviceID }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if let device {
|
||||
/*if let fridge = device.fridge {
|
||||
WatchFridgeControls(deviceID: device.id, fridge: fridge)
|
||||
}*/
|
||||
|
||||
Section {
|
||||
ForEach(device.snapshot?.metrics.filter { $0.value != nil } ?? []) { metric in
|
||||
LabeledContent(metric.label) {
|
||||
Text(metric.formattedWithUnit)
|
||||
.monospacedDigit()
|
||||
}
|
||||
.font(.footnote)
|
||||
}
|
||||
} header: {
|
||||
Text("Messwerte")
|
||||
}
|
||||
|
||||
Section {
|
||||
if let fault = device.snapshot?.fault {
|
||||
Label(fault, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
if let state = device.snapshot?.state {
|
||||
Text(state).font(.caption2)
|
||||
}
|
||||
ForEach(device.snapshot?.offReasons ?? [], id: \.self) { reason in
|
||||
Text(reason)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Label(device.link.label, systemImage: "antenna.radiowaves.left.and.right")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
Text("Gerät ist nicht mehr im Fahrzeugprofil.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.navigationTitle(device?.name ?? "Gerät")
|
||||
// Die Kühlbox wird nur verbunden, solange jemand sie ansieht. Das gilt
|
||||
// auch von hier aus – das iPhone verbindet auf Zuruf.
|
||||
.onAppear {
|
||||
//guard device?.fridge != nil else { return }
|
||||
//link.send(.fridgeSession(device: deviceID, wanted: true))
|
||||
}
|
||||
.onDisappear {
|
||||
// guard device?.fridge != nil else { return }
|
||||
//link.send(.fridgeSession(device: deviceID, wanted: false))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Die Neigung am Fahrzeug selbst, wie am iPhone – nur auf Uhrgrösse gebracht.
|
||||
///
|
||||
/// Zwei Ansichten, jede um ihre Achse gekippt: die Seitenansicht für längs
|
||||
/// (Front links, Heck rechts) und die Heckansicht für quer. Die Heckansicht
|
||||
/// teilt die Blickrichtung des Fahrers, links im Bild ist also links am
|
||||
/// Fahrzeug.
|
||||
struct WatchVehicleView: View {
|
||||
let pitch: Double?
|
||||
let roll: Double?
|
||||
|
||||
/// Wie am iPhone dreifach überhöht – zwei Grad wären sonst kaum zu sehen.
|
||||
/// Die angeschriebenen Gradzahlen bleiben echt.
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
panel(image: "VehicleSide", angle: pitch, title: "Längs",
|
||||
lower: "Front", upper: "Heck", aspect: VehicleTilt.sideAspect)
|
||||
panel(image: "VehicleRear", angle: roll, title: "Quer",
|
||||
lower: "links", upper: "rechts", aspect: VehicleTilt.rearAspect)
|
||||
}
|
||||
}
|
||||
|
||||
private func panel(image: String,
|
||||
angle: Double?,
|
||||
title: String,
|
||||
lower: String,
|
||||
upper: String,
|
||||
aspect: Double) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
HStack {
|
||||
Text(title)
|
||||
Spacer()
|
||||
Text(angle.map { String(format: "%.1f°", $0) } ?? "–")
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(VehicleTilt.colour(for: angle))
|
||||
}
|
||||
.font(.caption2)
|
||||
|
||||
ZStack {
|
||||
// Die Waagerechte als Bezug – ohne sie ist eine kleine Neigung
|
||||
// nicht einzuschätzen.
|
||||
Rectangle()
|
||||
.fill(Color.secondary.opacity(0.35))
|
||||
.frame(height: 1)
|
||||
|
||||
Image(image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.foregroundStyle(VehicleTilt.colour(for: angle))
|
||||
.aspectRatio(aspect, contentMode: .fit)
|
||||
.rotationEffect(.degrees(-(angle ?? 0) * VehicleTilt.exaggeration))
|
||||
.animation(.spring(duration: 0.4), value: angle)
|
||||
.opacity(angle == nil ? 0.3 : 1)
|
||||
}
|
||||
.frame(height: 52)
|
||||
|
||||
HStack {
|
||||
Text(lower)
|
||||
Spacer()
|
||||
Text(upper)
|
||||
}
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user