This commit is contained in:
BiasF
2026-08-30 10:36:52 +02:00
commit fdcc644828
30 changed files with 4152 additions and 0 deletions
+239
View File
@@ -0,0 +1,239 @@
import SwiftUI
/// Einrichten eines neuen Geräts: scannen, auswählen, benennen.
struct AddDeviceView: View {
@Environment(DeviceStore.self) private var store
@Environment(BluetoothManager.self) private var bluetooth
@Environment(\.dismiss) private var dismiss
@State private var selected: Discovery?
/// Auf einem Stellplatz sind dutzende fremde Geräte in Reichweite.
@State private var showsAllDevices = false
/// Nur Geräte anzeigen, die in den letzten Sekunden zu hören waren
/// sonst füllt sich die Liste in Wohnmobilparks endlos.
private var visibleDiscoveries: [Discovery] {
// Bewusst nicht nach Signalstärke sortieren: die schwankt im
// Sekundentakt und die Liste würde unter dem Finger springen.
bluetooth.discoveries.values
.filter { showsAllDevices || $0.isVictron || $0.looksLikeDaly }
.sorted { lhs, rhs in
if lhs.isVictron != rhs.isVictron { return lhs.isVictron }
if lhs.looksLikeDaly != rhs.looksLikeDaly { return lhs.looksLikeDaly }
return lhs.firstSeen < rhs.firstSeen
}
}
private var alreadyAdded: Set<UUID> {
Set(store.activeDevices.map(\.peripheralID))
}
var body: some View {
NavigationStack {
List {
Picker("Anzeige", selection: $showsAllDevices) {
Text("Passende Geräte").tag(false)
Text("Alle").tag(true)
}
.pickerStyle(.segmented)
.listRowBackground(Color.clear)
Section {
if visibleDiscoveries.isEmpty {
Label(showsAllDevices ? "Suche…" : "Noch nichts Passendes gefunden",
systemImage: "antenna.radiowaves.left.and.right")
.foregroundStyle(.secondary)
}
ForEach(visibleDiscoveries) { discovery in
Button {
selected = discovery
} label: {
row(for: discovery)
}
.disabled(alreadyAdded.contains(discovery.id))
}
} header: {
HStack {
Text("Gefundene Geräte")
Spacer()
ProgressView().controlSize(.small)
}
} footer: {
Text("Victron-Geräte werden automatisch erkannt. Damit sie hier "
+ "auftauchen, muss „Instant Readout“ in VictronConnect aktiv sein. "
+ "Das BMS meldet sich meist als „DL-…“ findest du es nicht, "
+ "auf „Alle“ umschalten.")
}
}
.navigationTitle("Gerät für \(store.activeProfile?.name ?? "Camper")")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Fertig") { dismiss() }
}
}
.sheet(item: $selected) { discovery in
ConfigureDeviceView(discovery: discovery) {
dismiss()
}
}
.onAppear {
bluetooth.clearDiscoveries()
bluetooth.isDiscovering = true
}
.onDisappear {
bluetooth.isDiscovering = false
}
}
}
private func row(for discovery: Discovery) -> some View {
HStack(spacing: 12) {
Image(systemName: discovery.isVictron ? "bolt.circle.fill"
: discovery.looksLikeDaly ? "battery.100percent" : "dot.radiowaves.left.and.right")
.font(.title3)
.foregroundStyle(discovery.isVictron || discovery.looksLikeDaly ? Color.accentColor : Color.secondary)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(discovery.displayName)
.foregroundStyle(.primary)
Text(discovery.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if alreadyAdded.contains(discovery.id) {
Text("Hinzugefügt")
.font(.caption)
.foregroundStyle(.secondary)
} else {
SignalBars(rssi: discovery.rssi)
}
}
}
}
/// Formular für ein neu gewähltes Gerät.
private struct ConfigureDeviceView: View {
let discovery: Discovery
let onSaved: () -> Void
@Environment(DeviceStore.self) private var store
@Environment(BluetoothManager.self) private var bluetooth
@Environment(\.dismiss) private var dismiss
@State private var name: String = ""
@State private var role: DeviceRole = .solarCharger
@State private var key: String = ""
private var needsKey: Bool { role.transport == .advertisement }
private var keyIsValid: Bool { key.hexBytes?.count == 16 }
private var canSave: Bool {
!name.trimmingCharacters(in: .whitespaces).isEmpty && (!needsKey || key.isEmpty || keyIsValid)
}
var body: some View {
NavigationStack {
Form {
Section("Gerät") {
LabeledContent("Gefunden als", value: discovery.displayName)
TextField("Name", text: $name)
Picker("Art", selection: $role) {
ForEach(DeviceRole.allCases) { role in
Text(role.title).tag(role)
}
}
}
if needsKey {
Section {
TextField("32 Hex-Zeichen", text: $key)
.font(.system(.body, design: .monospaced))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
if !key.isEmpty && !keyIsValid {
Label("Der Schlüssel muss 16 Byte (32 Hex-Zeichen) haben.",
systemImage: "exclamationmark.circle")
.font(.caption)
.foregroundStyle(.orange)
}
} header: {
Text("Verschlüsselungsschlüssel")
} footer: {
Text("VictronConnect → Gerät → Zahnrad → ⋮ → Produkt-Info → "
+ "„Instant Readout“ aktivieren → Verschlüsselungsdaten anzeigen. "
+ "Kann auch später nachgetragen werden.")
}
}
}
.navigationTitle("Einrichten")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Sichern", action: save).disabled(!canSave)
}
}
.onAppear(perform: prefill)
}
}
/// Aus dem Advertisement lässt sich Art und Name oft schon erraten.
private func prefill() {
if let recordType = discovery.victronRecordType {
switch VictronAdvertisement.RecordType(rawValue: recordType) {
case .solarCharger: role = .solarCharger
case .dcdcConverter, .orionXS: role = .chargeBooster
case .batteryMonitor: role = .batteryMonitor
default: role = .solarCharger
}
} else if discovery.looksLikeDaly {
role = .bms
}
name = discovery.name?.isEmpty == false ? discovery.name! : role.title
}
private func save() {
let device = ConfiguredDevice(
name: name.trimmingCharacters(in: .whitespaces),
role: role,
profileID: store.activeProfileID,
peripheralID: discovery.id,
advertisedName: discovery.name
)
store.add(device, victronKey: needsKey && keyIsValid ? key : nil)
bluetooth.refreshConfiguration()
dismiss()
onSaved()
}
}
/// Signalstärke als drei Balken.
struct SignalBars: View {
let rssi: Int
private var level: Int {
switch rssi {
case (-60)...: return 3
case (-75)..<(-60): return 2
case (-90)..<(-75): return 1
default: return 0
}
}
var body: some View {
HStack(alignment: .bottom, spacing: 2) {
ForEach(1...3, id: \.self) { bar in
RoundedRectangle(cornerRadius: 1)
.fill(bar <= level ? Color.accentColor : Color.secondary.opacity(0.25))
.frame(width: 3, height: CGFloat(bar) * 4 + 2)
}
}
.accessibilityLabel("Signalstärke \(level) von 3")
}
}
+109
View File
@@ -0,0 +1,109 @@
import SwiftUI
struct DashboardView: View {
@Environment(DeviceStore.self) private var store
@Environment(BluetoothManager.self) private var bluetooth
@State private var isAddingDevice = false
@State private var isManagingProfiles = false
private let columns = [GridItem(.adaptive(minimum: 300), spacing: 16)]
var body: some View {
NavigationStack {
ScrollView {
if !bluetooth.isBluetoothReady {
statusBanner
}
if store.activeDevices.isEmpty {
emptyState
} else {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(store.activeDevices) { device in
NavigationLink {
DeviceDetailView(device: device)
} label: {
DeviceCard(
device: device,
snapshot: bluetooth.snapshots[device.id],
linkState: bluetooth.linkStates[device.id] ?? .searching
)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
.padding(.top, 8)
}
}
.background(Color(.systemGroupedBackground))
.navigationTitle(store.activeProfile?.name ?? "Camper")
.toolbar {
ToolbarItem(placement: .topBarLeading) {
profileMenu
}
ToolbarItem(placement: .primaryAction) {
Button("Gerät hinzufügen", systemImage: "plus") {
isAddingDevice = true
}
}
}
.sheet(isPresented: $isAddingDevice) {
AddDeviceView()
}
.sheet(isPresented: $isManagingProfiles) {
ProfilesView()
}
}
}
/// Umschalter zwischen den Fahrzeugen.
private var profileMenu: some View {
Menu {
Picker("Fahrzeug", selection: Binding(
get: { store.activeProfileID },
set: { id in
guard let profile = store.profiles.first(where: { $0.id == id }) else { return }
store.selectProfile(profile)
bluetooth.refreshConfiguration()
}
)) {
ForEach(store.profiles) { profile in
Label(profile.name, systemImage: profile.symbol).tag(profile.id)
}
}
Divider()
Button("Fahrzeuge verwalten…", systemImage: "gearshape") {
isManagingProfiles = true
}
} label: {
Label(store.activeProfile?.name ?? "Fahrzeug",
systemImage: store.activeProfile?.symbol ?? "box.truck")
.labelStyle(.iconOnly)
}
}
private var statusBanner: some View {
Label(bluetooth.bluetoothStatusText, systemImage: "exclamationmark.triangle.fill")
.font(.callout)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.orange.opacity(0.15), in: .rect(cornerRadius: 12))
.padding(.horizontal)
.padding(.top, 8)
}
private var emptyState: some View {
ContentUnavailableView {
Label("Noch keine Geräte", systemImage: "antenna.radiowaves.left.and.right")
} description: {
Text("Füge \(store.activeProfile.map { "\($0.name)" } ?? "diesem Fahrzeug") "
+ "den Ladebooster, den Solarladeregler und das BMS hinzu.")
} actions: {
Button("Gerät suchen") { isAddingDevice = true }
.buttonStyle(.borderedProminent)
}
.padding(.top, 60)
}
}
+137
View File
@@ -0,0 +1,137 @@
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 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)
}
}
private var placeholderText: String {
switch linkState {
case .needsKey: return "Verschlüsselungsschlüssel fehlt im Detail eintragen."
case .failed(let message): return message
default: return "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
}
}
+298
View File
@@ -0,0 +1,298 @@
import Charts
import SwiftUI
struct DeviceDetailView: View {
let device: ConfiguredDevice
@Environment(DeviceStore.self) private var store
@Environment(BluetoothManager.self) private var bluetooth
@Environment(\.dismiss) private var dismiss
@State private var editedName = ""
@State private var keyInput = ""
@State private var showDeleteConfirmation = false
private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] }
private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching }
private var samples: [HistorySample] { bluetooth.history[device.id] ?? [] }
var body: some View {
List {
statusSection
if let snapshot, !snapshot.metrics.isEmpty {
Section("Messwerte") {
ForEach(snapshot.metrics) { metric in
LabeledContent(metric.label) {
Text(metric.formattedWithUnit)
.monospacedDigit()
.foregroundStyle(metric.value == nil ? .secondary : .primary)
}
}
}
}
if samples.count > 1, let primary = snapshot?.primaryMetric {
Section("Verlauf \(primary.label)") {
Chart(samples) { sample in
AreaMark(x: .value("Zeit", sample.time),
y: .value(primary.label, sample.value))
.foregroundStyle(.tint.opacity(0.15))
LineMark(x: .value("Zeit", sample.time),
y: .value(primary.label, sample.value))
.foregroundStyle(.tint)
.interpolationMethod(.monotone)
}
.chartYAxisLabel(primary.unit)
.frame(height: 180)
.padding(.vertical, 8)
}
}
if let snapshot, !snapshot.cellVoltages.isEmpty {
cellSection(snapshot.cellVoltages)
}
if let snapshot, snapshot.temperatures.count > 1 {
Section("Temperaturen") {
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in
LabeledContent("Fühler \(index + 1)") {
Text(String(format: "%.0f °C", value)).monospacedDigit()
}
}
}
}
if device.role.transport == .advertisement {
keySection
diagnosticsSection
} else {
bmsDiagnosticsSection
}
settingsSection
}
.navigationTitle(device.name)
.navigationBarTitleDisplayMode(.inline)
.onAppear {
editedName = device.name
keyInput = store.victronKeyText(for: device.id) ?? ""
}
.confirmationDialog("Gerät entfernen?",
isPresented: $showDeleteConfirmation,
titleVisibility: .visible) {
Button("Entfernen", role: .destructive) {
store.remove(device)
bluetooth.refreshConfiguration()
dismiss()
}
} message: {
Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.")
}
}
// MARK: - Abschnitte
private var statusSection: some View {
Section {
LabeledContent("Verbindung") {
StatusDot(linkState: linkState, isStale: snapshot?.isStale ?? true)
}
if let state = snapshot?.state {
LabeledContent("Zustand", value: state)
}
if let fault = snapshot?.fault {
Label(fault, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
}
ForEach(snapshot?.offReasons ?? [], id: \.self) { reason in
Label(reason, systemImage: "pause.circle")
.foregroundStyle(.orange)
}
if let snapshot {
LabeledContent("Aktualisiert vor") {
Text(snapshot.timestamp, style: .relative)
}
.foregroundStyle(.secondary)
}
if let rssi = snapshot?.rssi {
LabeledContent("Signal", value: "\(rssi) dBm")
.foregroundStyle(.secondary)
}
}
}
private func cellSection(_ voltages: [Double]) -> some View {
let minimum = voltages.min() ?? 0
let maximum = voltages.max() ?? 0
return Section("Zellspannungen") {
Chart(Array(voltages.enumerated()), id: \.offset) { index, voltage in
// Kategoriale x-Achse: sonst stehen die Balken zwischen den
// Beschriftungen statt darüber.
BarMark(
x: .value("Zelle", "\(index + 1)"),
y: .value("Spannung", voltage)
)
.foregroundStyle(voltage == maximum ? Color.orange
: voltage == minimum ? Color.blue : Color.accentColor)
// Die Zellnummer als Beschriftung am Balken statt über die
// x-Achse die blendet Swift Charts in der Liste aus.
// Ab neun Zellen wird es zu eng, dann ordnet die Liste zu.
.annotation(position: .bottom, alignment: .center) {
if voltages.count <= 8 {
Text("\(index + 1)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
.chartXAxis(.hidden)
// Der interessante Bereich sind die letzten Millivolt, nicht die
// absolute Spannung deshalb eng um die Messwerte zoomen.
.chartYScale(domain: (minimum - 0.05)...(maximum + 0.05))
.chartYAxisLabel("V")
.frame(height: 160)
.padding(.vertical, 8)
ForEach(Array(voltages.enumerated()), id: \.offset) { index, voltage in
LabeledContent("Zelle \(index + 1)") {
Text(String(format: "%.3f V", voltage)).monospacedDigit()
}
}
}
}
private var keySection: some View {
Section {
TextField("32 Hex-Zeichen", text: $keyInput)
.font(.system(.body, design: .monospaced))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.onSubmit(saveKey)
Button("Schlüssel speichern", action: saveKey)
.disabled(keyInput.hexBytes?.count != 16)
} header: {
Text("Verschlüsselungsschlüssel")
} footer: {
Text("In VictronConnect: Gerät öffnen → Zahnrad → ⋮ → Produkt-Info → "
+ "„Instant Readout“ einschalten → Verschlüsselungsdaten anzeigen. "
+ "Der Schlüssel ist 16 Byte lang (32 Hex-Zeichen).")
}
}
/// Zeigt, was das Gerät unverschlüsselt sendet. Wichtigster Wert ist das
/// erste Schlüsselbyte: stimmt es nicht mit der Eingabe überein, gehört der
/// Schlüssel zu einem anderen Victron-Gerät.
@ViewBuilder
private var diagnosticsSection: some View {
if let info = bluetooth.diagnostics[device.id] {
Section {
LabeledContent("Gerät sendet als erstes Schlüsselbyte") {
Text(info.expectedKeyText)
.font(.body.monospaced())
.foregroundStyle(keyBytesAgree == false ? .red : .primary)
}
LabeledContent("Eingetragener Schlüssel beginnt mit") {
Text(enteredKeyText)
.font(.body.monospaced())
.foregroundStyle(keyBytesAgree == false ? .red : .secondary)
}
LabeledContent("Datensatz", value: info.recordName)
LabeledContent("Produkt-ID") {
Text(info.productIDText).font(.body.monospaced())
}
VStack(alignment: .leading, spacing: 4) {
Text("Rohdaten")
Text(info.rawHex)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
} header: {
Text("Diagnose")
} footer: {
if keyBytesAgree == false {
Text("Die beiden Bytes müssen übereinstimmen. Tun sie das nicht, "
+ "stammt der Schlüssel von einem anderen Victron-Gerät in "
+ "VictronConnect prüfen, ob wirklich dieses Gerät geöffnet war.")
} else {
Text("Diese Werte sendet das Gerät unverschlüsselt mit.")
}
}
}
}
/// nil, solange kein vollständiger Schlüssel eingetragen ist.
private var keyBytesAgree: Bool? {
guard let expected = bluetooth.diagnostics[device.id]?.expectedKeyFirstByte,
let entered = keyInput.hexBytes?.first else { return nil }
return expected == entered
}
private var enteredKeyText: String {
guard let byte = keyInput.hexBytes?.first else { return "" }
return String(format: "0x%02X", byte)
}
/// Welches Protokoll das BMS spricht und was zuletzt ankam.
@ViewBuilder
private var bmsDiagnosticsSection: some View {
if let info = bluetooth.bmsDiagnostics[device.id] {
Section {
LabeledContent("Erkanntes Protokoll", value: info.dialect)
if let service = info.serviceUUID {
LabeledContent("Dienst") {
Text(service).font(.caption.monospaced()).foregroundStyle(.secondary)
}
}
if let hex = info.lastResponseHex {
VStack(alignment: .leading, spacing: 4) {
Text("Letzte Antwort")
Text(hex)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
} header: {
Text("Diagnose")
} footer: {
Text("Die App probiert Daly (klassisch und Modbus) sowie JBD/Xiaoxiang "
+ "durch und übernimmt, was antwortet. Bleibt es bei „wird ermittelt“, "
+ "spricht das BMS ein anderes Protokoll dann hilft die Rohantwort weiter.")
}
}
}
private var settingsSection: some View {
Section("Einstellungen") {
TextField("Name", text: $editedName)
.onSubmit(saveName)
Button("Namen übernehmen", action: saveName)
.disabled(editedName.trimmingCharacters(in: .whitespaces).isEmpty
|| editedName == device.name)
LabeledContent("Typ", value: device.role.title)
LabeledContent("Bluetooth-ID") {
Text(device.peripheralID.uuidString.prefix(8) + "")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
Button("Gerät entfernen", role: .destructive) {
showDeleteConfirmation = true
}
}
}
// MARK: - Aktionen
private func saveKey() {
store.setVictronKey(keyInput, for: device.id)
bluetooth.refreshConfiguration()
}
private func saveName() {
var updated = device
updated.name = editedName.trimmingCharacters(in: .whitespaces)
store.update(updated)
}
}
+168
View File
@@ -0,0 +1,168 @@
import SwiftUI
/// Fahrzeuge anlegen, umbenennen und entfernen.
struct ProfilesView: View {
@Environment(DeviceStore.self) private var store
@Environment(BluetoothManager.self) private var bluetooth
@Environment(\.dismiss) private var dismiss
@State private var editing: Profile?
@State private var pendingDeletion: Profile?
var body: some View {
NavigationStack {
List {
Section {
ForEach(store.profiles) { profile in
row(for: profile)
}
} footer: {
Text("Jedes Fahrzeug hat seine eigenen Geräte. Die App liest immer "
+ "nur die des gewählten Fahrzeugs aus.")
}
Section {
Button("Fahrzeug hinzufügen", systemImage: "plus") {
let profile = store.addProfile(named: "Camper \(store.profiles.count + 1)")
editing = profile
}
}
}
.navigationTitle("Fahrzeuge")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Fertig") { dismiss() }
}
}
.sheet(item: $editing) { profile in
ProfileEditView(profile: profile)
}
.confirmationDialog("Fahrzeug entfernen?",
isPresented: .init(get: { pendingDeletion != nil },
set: { if !$0 { pendingDeletion = nil } }),
titleVisibility: .visible) {
Button("Entfernen", role: .destructive) {
if let pendingDeletion {
store.removeProfile(pendingDeletion)
bluetooth.refreshConfiguration()
}
pendingDeletion = nil
}
} message: {
if let pendingDeletion {
Text("\(pendingDeletion.name)“ und die \(store.deviceCount(in: pendingDeletion)) "
+ "darin eingerichteten Geräte werden gelöscht.")
}
}
}
}
private func row(for profile: Profile) -> some View {
HStack {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(profile.name)
Text(deviceSummary(for: profile))
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: profile.symbol)
}
Spacer()
if profile.id == store.activeProfileID {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
.fontWeight(.semibold)
}
}
.contentShape(.rect)
.onTapGesture {
store.selectProfile(profile)
bluetooth.refreshConfiguration()
}
.swipeActions(edge: .trailing) {
// Das letzte Fahrzeug muss bleiben, sonst hätten Geräte keinen Ort.
if store.hasMultipleProfiles {
Button("Entfernen", systemImage: "trash", role: .destructive) {
pendingDeletion = profile
}
}
Button("Bearbeiten", systemImage: "pencil") {
editing = profile
}
.tint(.gray)
}
}
private func deviceSummary(for profile: Profile) -> String {
let count = store.deviceCount(in: profile)
return count == 1 ? "1 Gerät" : "\(count) Geräte"
}
}
/// Name und Symbol eines Fahrzeugs ändern.
private struct ProfileEditView: View {
let profile: Profile
@Environment(DeviceStore.self) private var store
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var symbol = "box.truck"
private let columns = [GridItem(.adaptive(minimum: 60), spacing: 12)]
var body: some View {
NavigationStack {
Form {
Section("Name") {
TextField("Name", text: $name)
}
Section("Symbol") {
LazyVGrid(columns: columns, spacing: 12) {
ForEach(Profile.symbols, id: \.self) { candidate in
Button {
symbol = candidate
} label: {
Image(systemName: candidate)
.font(.title2)
.frame(width: 52, height: 52)
.background(symbol == candidate ? Color.accentColor.opacity(0.18)
: Color.secondary.opacity(0.08),
in: .rect(cornerRadius: 12))
.foregroundStyle(symbol == candidate ? Color.accentColor : Color.primary)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 4)
}
}
.navigationTitle("Fahrzeug")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Sichern") {
var updated = profile
updated.name = name.trimmingCharacters(in: .whitespaces)
updated.symbol = symbol
store.update(updated)
dismiss()
}
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.onAppear {
name = profile.name
symbol = profile.symbol
}
}
}
}