- Dynamic Island now shows only the level icon (compact/minimal); the
bubble, instruction text, and numbers move to the shared lock
screen/CarPlay ActivityView, which iOS renders identically on both
surfaces (no separate CarPlay layout API exists for this).
- Replace +/- signs with a leading direction letter (H/F for Heck-
Front, R/L for rechts-links) via a new shared LevelDirectionFormatting
helper, used by the level screen, alignment assistant, and dashboard
card. The generic "Messwerte" list keeps raw signed values.
- Live Activity's instruction now reads as two lines ("Heck steht
höher 1.8°" / "links steht höher 0.9°") instead of a sentence plus a
separate number column.
- Fix the bubble level graphic's pitch axis being inverted (front/rear
reversed) in all three copies (app, watch, Live Activity widget) —
confirmed against the known-correct VehicleTiltView orientation.
- Dashboard card shows pitch and roll side by side, large, without the
"Längsneigung"/"Querneigung" labels, for the leveling device.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
242 lines
9.2 KiB
Swift
242 lines
9.2 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, 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
|
||
}
|
||
}
|