first commit
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
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.looksLikeSupported }
|
||||
.sorted { lhs, rhs in
|
||||
//if lhs.isVictron != rhs.isVictron { return lhs.isVictron }
|
||||
if lhs.looksLikeSupported != rhs.looksLikeSupported { return lhs.looksLikeSupported }
|
||||
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-…“. Kühlboxen tragen oft "
|
||||
+ "einen kryptischen Namen – findest du dein Gerät 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.looksLikeSupported ? "battery.100percent" : "dot.radiowaves.left.and.right")
|
||||
.font(.title3)
|
||||
.foregroundStyle(/*discovery.isVictron || */discovery.looksLikeSupported ? 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 = .leveling
|
||||
@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.isLevelSensor {
|
||||
role = .leveling
|
||||
} /*else if let name = discovery.name?.lowercased(),
|
||||
["alpicool", "icecube", "ice cube", "fridge", "cool"].contains(where: name.contains) {
|
||||
role = .fridge
|
||||
} else if discovery.looksLikeSupported {
|
||||
role = .bms
|
||||
}*/
|
||||
// Funknamen wie "WTaEaAA25342229" taugen nicht als Anzeigename. Die
|
||||
// Art des Geräts ist der bessere Vorschlag; der Funkname steht
|
||||
// ohnehin darüber unter "Gefunden als".
|
||||
if discovery.isLevelSensor {
|
||||
name = "Nivellierung"
|
||||
} else if let advertised = discovery.name, advertised.count <= 20,
|
||||
advertised.contains(" ") || advertised.rangeOfCharacter(from: .decimalDigits) == nil {
|
||||
name = advertised
|
||||
} else {
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user