Jede Verbindung meldet sich am Display der Box an – sie piept, und wer gerade davorsteht, wird gestört. Dauerhaft verbunden zu sein ist dort also nicht unsichtbar wie beim BMS, sondern lästig. Die Kühlbox wird deshalb nur noch verbunden, solange ihre Ansicht offen ist: am iPhone über die Detailansicht, an der Uhr über einen eigenen Befehl, den sie beim Öffnen und Schliessen schickt. Ein Stellbefehl geht weiterhin immer durch – ist die Box nicht verbunden, wird er gemerkt und löst den Verbindungsaufbau aus. Abgeriegelt sind alle drei Wege, über die bisher verbunden wurde: beim Start, im Takt des Wiederverbindens, und – das war das eigentliche Loch – sobald das Gerät in den Werbedaten auftaucht. Da die App dauerhaft scannt, hätte allein dieser Weg die Box weiter angefunkt. Dass sie in Reichweite ist, ist kein Grund, sie anzufassen. Auf der Übersicht steht dafür der zuletzt gestellte Stand statt der Messwerte: Sollwert, Ein/Aus, Eco oder Max, dazu wann er gestellt wurde. Das bleibt richtig, auch wenn es von gestern ist – ein Sollwert ändert sich nur, wenn jemand ihn ändert. Eine Innentemperatur von gestern sähe dagegen aus wie eine von jetzt, und man würde ihr glauben. Genau das sichern die neuen Prüfungen ab: Hauptwert ist der Sollwert, gemessene Temperatur und Bordspannung kommen nicht vor. Der Stand liegt in den Einstellungen (FridgeSettings) mit einem Zeitstempel, der „zuletzt geändert" bedeutet und nicht „zuletzt gesehen" – sonst behauptete die Kachel Frische, wo sich nichts getan hat. Die Uhr bekommt dasselbe Bild: WatchFridge trägt jetzt ein Kennzeichen isLive. BMS und Neigungsmesser bleiben dauerhaft verbunden. Sie stören nicht, und ihre Werte will man laufend sehen. Nicht nachgezogen ist die Android-App – dort verbindet der BluetoothManager weiterhin dauerhaft. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
163 lines
5.7 KiB
Swift
163 lines
5.7 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 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)
|
||
}
|
||
}
|
||
|
||
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 device.role.connectsOnDemand, linkState != .live, 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.
|
||
private func lastSetText(_ snapshot: DeviceSnapshot) -> String {
|
||
let age = relativeUpdate(snapshot.timestamp)
|
||
guard let state = snapshot.state else { return "Zuletzt gestellt \(age)" }
|
||
return "\(state) · zuletzt gestellt \(age)"
|
||
}
|
||
|
||
/// „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
|
||
}
|
||
}
|