Auf der Kachel stand eine nackte Zahl, und das ist genau die Zahl, die man für die Innentemperatur hält. Jetzt steht "Soll" darüber – bei zwei Zonen "Soll links", mit "Soll rechts" gleich daneben statt in der Nebenwertzeile. Die Betriebsart bekommt ein eigenes Schild in Titelgrösse, rechts auf Höhe des Sollwerts: Eco oder Max in der Akzentfarbe hinterlegt, Aus grau. Ob die Box überhaupt läuft, ist die zweite Frage nach dem Sollwert, und Platz ist auf der Kachel reichlich. In der Fusszeile steht der Hinweis aufs Verbinden jetzt vorn: "Verbindet erst beim Öffnen · zuletzt gestellt vor 12 Minuten". Er erklärt, warum hier kein Messwert steht – das ist die Frage, die sich zuerst stellt, das Alter kommt danach. Im Vorführbetrieb steht die Kühlbox ebenfalls auf "zuletzt gestellt" statt live. Sonst zeigt er ein Verhalten, das es nicht mehr gibt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
225 lines
8.3 KiB
Swift
225 lines
8.3 KiB
Swift
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
|
||
}
|
||
}
|