Files
Camper-Monitor/CamperMonitorWatch/Views/WatchRootView.swift
T
BiasFandClaude Opus 5 0e05ca22d7 Komplikation fürs Zifferblatt, und die App heisst jetzt VanControl Pro
Zwei Dinge in einem Commit, weil sie sich dieselben Dateien teilen:
project.pbxproj und WatchRootView.swift tragen beides.

**Komplikation.** Neues Target CamperMonitorComplication, auf watchOS
sind Komplikationen WidgetKit-Widgets. Es wird in die Watch-App
eingebettet (PlugIns) und bietet die vier Zubehör-Familien an. Ein Tipp
öffnet die App direkt auf der Nivellierung statt in der Geräteliste:
Die Komplikation hängt eine Adresse an, die Watch-App führt dafür jetzt
einen Navigationsweg, den sie von aussen setzen kann. Die Adresse steht
in Shared/WatchDeepLink.swift, weil beide Seiten sie brauchen – ein
Tippfehler auf einer Seite bliebe sonst stumm.

Bewusst ohne Messwert: Eine Komplikation läuft in einem eigenen Prozess
und käme an die Werte nur über eine App-Gruppe, und die verlangt eine
bezahlte Mitgliedschaft. Ein Wert, der beim Blick aufs Zifferblatt
Stunden alt sein kann, wäre ohnehin schlimmer als keiner – man glaubt
ihm. Die Komplikation tut deshalb genau eine Sache, und die zuverlässig.

**Rebranding.** Der sichtbare Name ist überall VanControl Pro:
CFBundleDisplayName beider Apple-Targets, Android-Label, die Texte in
beiden Apps, beide READMEs und der Name der APK im Gitea-Ablauf.

Die Kennungen bleiben, wie sie sind – Bundle-IDs, Android-Paket,
Target-, Ordner- und Schemanamen. Eine geänderte Bundle-ID ist für iOS
eine neue App: Geräte, Fahrzeugprofile und Victron-Schlüssel wären weg,
dazu neue Profile und eine neu zu koppelnde Watch-App. Sichtbar ist
davon nichts, also gibt es dafür auch keinen Grund.

Geprüft: alle drei Apple-Targets bauen, die Erweiterung liegt im
Watch-Bundle unter PlugIns, die Anzeigenamen stimmen im gebauten
Bundle, Tests grün. Die neue Bundle-ID der Erweiterung braucht beim
ersten Lauf aus Xcode ein Profil, das Xcode selbst anlegt.

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

188 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 .align: WatchAlignView()
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 align
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
}
}
}