Files
Camper-Monitor/VanControl/Views/DeviceCard.swift
T
fototeddyandClaude Sonnet 5 303a9735d0 App-Namen im iOS-Projekt auf VanControl vereinheitlichen
CamperMonitor (Haupt-Repo) und VanAligneiOS (aus dem gemergten
solar-integration-Branch) liefen unter zwei verschiedenen internen
Namen, obwohl die App nach aussen längst einheitlich "VanControl Pro"
heisst. Jetzt durchgängig VanControl:

- Ordner: CamperMonitor/, CamperMonitorWatch/, CamperMonitorComplication/,
  VanAligneiOSWidget/ → VanControl/, VanControlWatch/,
  VanControlComplication/, VanControlWidget/
- Xcode-Projekt: CamperMonitor.xcodeproj → VanControl.xcodeproj, alle
  Targets/Schemes/Produktnamen entsprechend umbenannt
- Bundle-Identifier auf Wunsch mitgeändert: de.s0.fototeddy.VanControl*
  (App noch nicht veröffentlicht); dabei auch die
  WKCompanionAppBundleIdentifier-Werte korrigiert, die noch das alte
  de.fritob-Präfix statt des tatsächlichen de.s0.fototeddy-Präfixes
  trugen
- Swift-Dateien/Typen: CamperMonitorApp → VanControlApp,
  VanAligneiOSWidget* → VanControlWidget*
- Config/*-Info.plist umbenannt, README.md/Tools/README.md/run-tests.sh
  auf die neuen Pfade angepasst

Bewusst unverändert: firmware/vanalign und alle Bezüge auf "VanAlign"
als Namen der Neigungsmesser-Hardware (eigenständiges Produkt, kein
App-Name) sowie der komplette Android/-Ordner.

Build (App, Watch, Debug) und Protokoll-Testlauf grün.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 21:25:28 +02:00

225 lines
8.3 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
/// Kachel auf dem Dashboard: Hauptwert gross, darunter die wichtigsten
/// Nebenwerte und der Verbindungszustand.
struct DeviceCard: View {
let device: ConfiguredDevice
let snapshot: DeviceSnapshot?
let linkState: DeviceLinkState
var body: some View {
VStack(alignment: .leading, spacing: 12) {
header
if isShowingLastSettings, let snapshot {
lastSettings(for: snapshot)
} else if let snapshot, let primary = snapshot.primaryMetric {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(primary.formatted)
.font(.system(size: 44, weight: .semibold, design: .rounded))
.contentTransition(.numericText())
Text(primary.unit)
.font(.title3.weight(.medium))
.foregroundStyle(.secondary)
}
.foregroundStyle(snapshot.isStale ? .secondary : .primary)
secondaryValues(for: snapshot)
} else {
Text(placeholderText)
.font(.callout)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 18)
}
footer
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
}
private var header: some View {
HStack(spacing: 8) {
Image(systemName: device.role.symbol)
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 1) {
Text(device.name)
.font(.headline)
Text(device.role.title)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
StatusDot(linkState: linkState, isStale: snapshot?.isStale ?? true)
}
}
/// Ob hier der zuletzt gestellte Stand steht statt Messwerten.
private var isShowingLastSettings: Bool {
device.role.connectsOnDemand && linkState != .live
}
/// Die Kachel der Kühlbox, solange sie nicht verbunden ist.
///
/// Hier steht kein Messwert, sondern was zuletzt eingestellt war also
/// gehört „Soll“ dazu. Ohne das läse man die Zahl als Innentemperatur, und
/// genau dieser Irrtum wäre teuer.
private func lastSettings(for snapshot: DeviceSnapshot) -> some View {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text(snapshot.metrics.count > 1 ? "Soll links" : "Soll")
.font(.caption)
.foregroundStyle(.secondary)
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(snapshot.primaryMetric?.formatted ?? "")
.font(.system(size: 44, weight: .semibold, design: .rounded))
Text(snapshot.primaryMetric?.unit ?? "")
.font(.title3.weight(.medium))
.foregroundStyle(.secondary)
}
}
if let right = snapshot.metrics.first(where: { $0.key == "target_right" }),
right.value != nil {
VStack(alignment: .leading, spacing: 2) {
Text("Soll rechts")
.font(.caption)
.foregroundStyle(.secondary)
Text(right.formattedWithUnit)
.font(.title2.weight(.semibold))
.monospacedDigit()
}
}
Spacer(minLength: 0)
modeBadge(snapshot.state)
}
}
/// Betriebsart als Schild: Auf der Kachel ist Platz, und ob die Box läuft
/// oder aus ist, ist die zweite Frage nach dem Sollwert.
@ViewBuilder
private func modeBadge(_ state: String?) -> some View {
if let state {
let isOff = state == "Aus"
Text(state)
.font(.title3.weight(.semibold))
.foregroundStyle(isOff ? Color.secondary : Color.accentColor)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(isOff ? Color.secondary.opacity(0.15)
: Color.accentColor.opacity(0.15),
in: .rect(cornerRadius: 12))
}
}
private func secondaryValues(for snapshot: DeviceSnapshot) -> some View {
let others = snapshot.metrics
.filter { $0.id != snapshot.primaryMetric?.id && $0.value != nil }
.prefix(3)
return HStack(spacing: 16) {
ForEach(Array(others)) { metric in
VStack(alignment: .leading, spacing: 1) {
Text(metric.formattedWithUnit)
.font(.subheadline.weight(.medium))
.monospacedDigit()
Text(metric.label)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 0)
}
}
@ViewBuilder
private var footer: some View {
if isShowingLastSettings, let snapshot {
// Kein Messwert, sondern der zuletzt gestellte Stand. Das gehört
// dazugeschrieben, sonst liest man ihn als aktuelle Temperatur.
Text(lastSetText(snapshot))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
} else if let fault = snapshot?.fault {
Label(fault, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.red)
.lineLimit(2)
} else if let state = snapshot?.state {
// Bei "Aus" ist erst der Grund die eigentliche Information.
let reason = snapshot?.offReasons.first
Text(reason.map { "\(state) · \($0)" } ?? state)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
} else if case .failed(let message) = linkState {
Text(message)
.font(.caption)
.foregroundStyle(.orange)
.lineLimit(2)
}
}
/// Was unter einem Gerät steht, das nur beim Öffnen verbunden wird.
///
/// Der Hinweis aufs Verbinden steht vorn: Er erklärt, warum hier kein
/// Messwert steht, und das ist die Frage, die sich zuerst stellt.
private func lastSetText(_ snapshot: DeviceSnapshot) -> String {
"Verbindet erst beim Öffnen · zuletzt gestellt \(relativeUpdate(snapshot.timestamp))"
}
/// „vor 3 Minuten“ statt einer Uhrzeit auf der Kachel zählt das Alter.
private func relativeUpdate(_ date: Date) -> String {
let formatter = RelativeDateTimeFormatter()
formatter.locale = Locale(identifier: "de_DE")
formatter.unitsStyle = .full
return formatter.localizedString(for: date, relativeTo: Date())
}
private var placeholderText: String {
switch linkState {
case .needsKey: return "Verschlüsselungsschlüssel fehlt im Detail eintragen."
case .failed(let message): return message
default:
return device.role.connectsOnDemand
? "Verbindet erst beim Öffnen."
: "Warte auf Daten…"
}
}
}
/// Kleiner Punkt, der Verbindungszustand und Aktualität zusammenfasst.
struct StatusDot: View {
let linkState: DeviceLinkState
let isStale: Bool
var body: some View {
HStack(spacing: 5) {
Circle()
.fill(color)
.frame(width: 8, height: 8)
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
private var color: Color {
switch linkState {
case .live: return isStale ? .orange : .green
case .needsKey: return .orange
case .failed: return .red
default: return .secondary
}
}
private var label: String {
if case .live = linkState, isStale { return "Veraltet" }
return linkState.label
}
}