forked from fritob/Camper-Monitor
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>
169 lines
6.5 KiB
Swift
169 lines
6.5 KiB
Swift
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)
|
||
}
|
||
}
|