Files
Camper-Monitor/VanControl/Views/ProfilesView.swift
T
fototeddyandClaude Sonnet 5 8e5ee6927e README aktualisieren: Demo-Modus, Votronic-Quelle, keine persönlichen Pfade
* Demo-Modus-Abschnitt an den tatsächlichen Code angeglichen: Schalter in
  den Einstellungen (für lokal installierte Builds ohne Xcode) und
  Umgebungsvariable, beide dokumentiert.
* Quelle für den Votronic-Solarregler ergänzt (syssi/esphome-votronic),
  eigener Abschnitt analog zu Kühlbox/WattCycle.
* Persönlichen Pfad aus der Bauanleitung entfernt (open VanControl.xcodeproj
  statt eines lokalen Nutzerpfads); Beispiel-Bundle-ID im Demo-Modus-Befehl
  durch Platzhalter ersetzt.

Zusätzlich: Symbolliste für Fahrzeugprofile auf Wunsch exakt auf die aus
solar-integration zurückgesetzt (car, car.side, suv.side, truck.pickup.side,
box.truck, bus), statt sie mit dev_watchs Liste zusammenzuführen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 22:11:36 +02:00

233 lines
9.1 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("Antippen wählt das Fahrzeug aus, das ⓘ öffnet Name, Symbol "
+ "und Maße. Jedes Fahrzeug hat seine eigenen Geräte, und die "
+ "App liest immer nur die des gewählten 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(spacing: 12) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(profile.name)
Text(summary(for: profile))
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: profile.symbol)
}
// Nur dieser Bereich wählt aus; der Knopf rechts bleibt frei.
.contentShape(.rect)
.onTapGesture {
store.selectProfile(profile)
bluetooth.refreshConfiguration()
}
Spacer(minLength: 0)
if profile.id == store.activeProfileID {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
.fontWeight(.semibold)
}
// Zum Bearbeiten reichte bisher nur das Wischen - das findet
// niemand, erst recht nicht für die Fahrzeugmaße.
Button {
editing = profile
} label: {
Image(systemName: "info.circle")
.font(.title3)
}
.buttonStyle(.borderless)
.accessibilityLabel("\(profile.name) bearbeiten")
}
.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 summary(for profile: Profile) -> String {
let count = store.deviceCount(in: profile)
var parts = [count == 1 ? "1 Gerät" : "\(count) Geräte"]
if profile.trackWidth != nil || profile.wheelbase != nil {
parts.append("Maße hinterlegt")
}
return parts.joined(separator: " · ")
}
}
/// Name, Symbol und Maße eines Fahrzeugs ändern.
private struct ProfileEditView: View {
/// Nimmt Komma wie Punkt an auf einer deutschen Tastatur liegt das Komma
/// näher. Unplausible Werte werden verworfen, damit die Keilhöhe nicht
/// stillschweigend Unsinn ergibt.
static func metres(from text: String) -> Double? {
let cleaned = text.replacingOccurrences(of: ",", with: ".")
.trimmingCharacters(in: .whitespaces)
guard let value = Double(cleaned), value > 0.5, value < 12 else { return nil }
return value
}
static func text(from value: Double?) -> String {
guard let value else { return "" }
return String(format: "%.2f", value).replacingOccurrences(of: ".", with: ",")
}
let profile: Profile
@Environment(DeviceStore.self) private var store
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var symbol = "suv.side"
@State private var trackWidth = ""
@State private var wheelbase = ""
private let columns = [GridItem(.adaptive(minimum: 60), spacing: 12)]
var body: some View {
NavigationStack {
Form {
Section("Name") {
TextField("Name", text: $name)
}
Section {
LabeledContent("Spurweite") {
HStack {
TextField("z. B. 2,00", text: $trackWidth)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
Text("m").foregroundStyle(.secondary)
}
}
LabeledContent("Radstand") {
HStack {
TextField("z. B. 3,50", text: $wheelbase)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
Text("m").foregroundStyle(.secondary)
}
}
} header: {
Text("Fahrzeugmaße")
} footer: {
Text("Nur für den Ausrichtungs-Assistenten: Aus Neigung und "
+ "Abstand errechnet er, wie hoch der Auffahrkeil sein muss. "
+ "Ohne Angabe entfällt diese Anzeige.")
}
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
updated.trackWidth = Self.metres(from: trackWidth)
updated.wheelbase = Self.metres(from: wheelbase)
store.update(updated)
dismiss()
}
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.onAppear {
name = profile.name
symbol = profile.symbol
trackWidth = Self.text(from: profile.trackWidth)
wheelbase = Self.text(from: profile.wheelbase)
}
}
}
}