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

169 lines
6.5 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.
///
/// 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 unten 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 {
@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)
if let lift, !lift.isNegligible {
WheelLiftPlan(lift: lift, isCompact: true)
.padding(.top, 2)
}
NavigationLink(value: WatchRoute.align) {
Label("Ausrichten", systemImage: "arrow.left.and.right")
}
.buttonStyle(.borderedProminent)
Button {
showsVehicle.toggle()
} label: {
Label(showsVehicle ? "Libelle" : "Fahrzeug",
systemImage: showsVehicle ? "circle.circle" : "box.truck")
}
.font(.caption2)
SourceNote(reading: reading)
}
.padding(.horizontal, 4)
}
.navigationTitle("Nivellierung")
}
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)
}
}
/// 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)
}
}