first commit

This commit is contained in:
fototeddy
2026-09-04 21:30:51 +02:00
commit da64b1b6bd
160 changed files with 20664 additions and 0 deletions
+232
View File
@@ -0,0 +1,232 @@
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 = "box.truck"
@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)
}
}
}
}