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, snapshot.primaryMetric != nil, device.role == .leveling { levelReadout(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)) } } /// Für die Nivellierung: Front/Heck und links/rechts nebeneinander, groß, /// ohne "Längsneigung"/"Querneigung"-Beschriftung – die Richtung steht ja /// schon im vorangestellten Buchstaben. private func levelReadout(for snapshot: DeviceSnapshot) -> some View { let pitch = snapshot.metrics.first(where: { $0.key == "pitch" })?.value let roll = snapshot.metrics.first(where: { $0.key == "roll" })?.value return HStack(spacing: 24) { Text(LevelDirectionFormatting.pitchTile(pitch)) Text(LevelDirectionFormatting.rollTile(roll)) } .font(.system(size: 36, weight: .semibold, design: .rounded)) .contentTransition(.numericText()) .foregroundStyle(snapshot.isStale ? .secondary : .primary) } 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 } }