Compare commits

..
Author SHA1 Message Date
fototeddyandClaude Sonnet 5 644c4c7767 Rename solar integration types to VotronicSolarESP
Renames every type and identifier we built for the solar charge
controller bridge from generic "Solar" naming to the product name
VotronicSolarESP: VanAlignSolarProtocol -> VotronicSolarESPProtocol,
SolarSession -> VotronicSolarESPSession, SolarState ->
VotronicSolarESPState, plus the dependent properties, parameters, and
user-facing discovery/empty-state strings.

Purely a Swift-side rename - the underlying BLE service/characteristic
UUIDs are untouched, so it has no effect on communicating with the
actual firmware. DeviceRole.solar's case name, and DemoData's device
instance variables, are left as-is since they describe the role/demo
device rather than this specific integration code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 20:22:04 +02:00
fototeddyandClaude Sonnet 5 7fc9442aeb Add in-app demo mode toggle, enable alignment assistant in demo data
CAMPER_DEMO=1 only works when Xcode or simctl launches the process, so
a build installed locally and opened from the home screen had no way
to enable demo mode at all. Demo mode is now also a persisted toggle
under Settings > Entwicklung (Debug builds only), checked alongside the
environment variable. Takes effect after an app restart since
DeviceStore/BluetoothManager only read it at init.

Also mark the demo leveling sensor as live-connected - it had a level
reading but never a live link state, which left the Ausrichtungs-
Assistent button (and Live Activity toggle) disabled in demo mode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:55:47 +02:00
fototeddyandClaude Sonnet 5 8d975d2c4d Add landscape layouts for level display, vehicle view, and alignment assistant
Rotating the phone into landscape while leveling used to leave a
portrait-shaped column of controls that needed scrolling to take in.
All three level-integration screens now branch on verticalSizeClass:
the bubble/vehicle status in the device screen and the full alignment
assistant (advice, readings, and wedge heights) lay out side-by-side
instead of stacked, sized to fit without scrolling.

The device screen also measures its actual available height via a
GeometryReader and feeds it to the level display so the bubble/vehicle
graphic scales to fill the landscape screen rather than being capped at
a fixed size, and the connection-status section moves to the bottom of
the list in landscape so it doesn't compete with the display for space.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:37:15 +02:00
fototeddyandClaude Sonnet 5 21f5d8de62 Simplify CarPlay Live Activity to letter+arrow glance indicator
Replaces the full sentence ("Heck steht höher, links steht höher") in
the .small activity family with a compact "H ↑ L" style readout: a
direction letter per axis (only shown when outside tolerance) around a
single up arrow, since only the higher side is ever named anyway. Text
now shares the same green/orange/red scale as the bubble glyph (factored
into one levelColour helper instead of two duplicated thresholds), and
uses a scalable font style with minimumScaleFactor so it fits whatever
size CarPlay actually grants the card - there's no API to query the
physical display size from Live Activity content.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:09:29 +02:00
fototeddyandClaude Sonnet 5 7dcb564cbf Debounce Live Activity ending on BLE disconnect, auto-resume on reconnect
A dropped BLE connection ended the Live Activity immediately, which
flickered off and on during the brief, routine disconnects that happen
constantly while driving. It now waits 12s before actually ending, and
if the sensor reconnects within that window the pending end is simply
cancelled. If the disconnect does outlast the grace period and the
activity really ends, reconnecting afterwards restarts it automatically
with the last known reading, since that wasn't a deliberate user stop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:09:21 +02:00
15 changed files with 591 additions and 296 deletions
+42 -22
View File
@@ -18,7 +18,7 @@ struct Discovery: Identifiable, Hashable {
/// Der Neigungsmesser bewirbt seinen Dienst, ist also sicher erkennbar. /// Der Neigungsmesser bewirbt seinen Dienst, ist also sicher erkennbar.
var isLevelSensor = false var isLevelSensor = false
/// Der Solarladeregler bewirbt seinen Dienst ebenso. /// Der Solarladeregler bewirbt seinen Dienst ebenso.
var isSolarSensor = false var isVotronicSolarESPSensor = false
//var isVictron: Bool { victronRecordType != nil } //var isVictron: Bool { victronRecordType != nil }
@@ -33,7 +33,7 @@ struct Discovery: Identifiable, Hashable {
var subtitle: String { var subtitle: String {
if isLevelSensor { return "VanAlign Neigungsmesser" } if isLevelSensor { return "VanAlign Neigungsmesser" }
if isSolarSensor { return "VanAlign Solarladeregler" } if isVotronicSolarESPSensor { return "VotronicSolarESP" }
return "Bluetooth-Gerät" return "Bluetooth-Gerät"
} }
/*var subtitle: String { /*var subtitle: String {
@@ -164,7 +164,7 @@ final class BluetoothManager: NSObject {
//private(set) var bmsDiagnostics: [UUID: BMSDiagnostics] = [:] //private(set) var bmsDiagnostics: [UUID: BMSDiagnostics] = [:]
//private(set) var fridgeStates: [UUID: AlpicoolState] = [:] //private(set) var fridgeStates: [UUID: AlpicoolState] = [:]
private(set) var levelStates: [UUID: LevelState] = [:] private(set) var levelStates: [UUID: LevelState] = [:]
private(set) var solarStates: [UUID: SolarState] = [:] private(set) var votronicSolarESPStates: [UUID: VotronicSolarESPState] = [:]
private(set) var isBluetoothReady = false private(set) var isBluetoothReady = false
private(set) var bluetoothStatusText = "Bluetooth wird gestartet…" private(set) var bluetoothStatusText = "Bluetooth wird gestartet…"
@@ -201,7 +201,7 @@ final class BluetoothManager: NSObject {
//private var bmsSessions: [UUID: BMSSession] = [:] //private var bmsSessions: [UUID: BMSSession] = [:]
private var levelSessions: [UUID: LevelSession] = [:] private var levelSessions: [UUID: LevelSession] = [:]
private var solarSessions: [UUID: SolarSession] = [:] private var votronicSolarESPSessions: [UUID: VotronicSolarESPSession] = [:]
private var connectedPeripherals: [UUID: CBPeripheral] = [:] private var connectedPeripherals: [UUID: CBPeripheral] = [:]
private var reconnectTimer: DispatchSourceTimer? private var reconnectTimer: DispatchSourceTimer?
@@ -280,11 +280,15 @@ final class BluetoothManager: NSObject {
bluetoothStatusText = "Demo-Modus" bluetoothStatusText = "Demo-Modus"
//fridgeStates[DemoData.fridge.id] = DemoData.fridgeState //fridgeStates[DemoData.fridge.id] = DemoData.fridgeState
levelStates[DemoData.level.id] = DemoData.levelState levelStates[DemoData.level.id] = DemoData.levelState
// Ohne "live" bleibt der Ausrichtungs-Assistent (und die Live
// Activity) im Demo-Modus deaktiviert, weil beide echte Messwerte
// voraussetzen.
linkStates[DemoData.level.id] = .live
solarStates[DemoData.solar.id] = DemoData.solarState votronicSolarESPStates[DemoData.solar.id] = DemoData.votronicSolarESPState
linkStates[DemoData.solar.id] = .live linkStates[DemoData.solar.id] = .live
record(DemoData.solarState.snapshot(deviceID: DemoData.solar.id, rssi: -58)) record(DemoData.votronicSolarESPState.snapshot(deviceID: DemoData.solar.id, rssi: -58))
history[DemoData.solar.id] = DemoData.solarHistory() history[DemoData.solar.id] = DemoData.votronicSolarESPHistory()
/* for snapshot in DemoData.snapshots() { /* for snapshot in DemoData.snapshots() {
snapshots[snapshot.deviceID] = snapshot snapshots[snapshot.deviceID] = snapshot
linkStates[snapshot.deviceID] = .live linkStates[snapshot.deviceID] = .live
@@ -342,7 +346,7 @@ final class BluetoothManager: NSObject {
//bmsDiagnostics = bmsDiagnostics.filter { known.contains($0.key) } //bmsDiagnostics = bmsDiagnostics.filter { known.contains($0.key) }
//fridgeStates = fridgeStates.filter { known.contains($0.key) } //fridgeStates = fridgeStates.filter { known.contains($0.key) }
levelStates = levelStates.filter { known.contains($0.key) } levelStates = levelStates.filter { known.contains($0.key) }
solarStates = solarStates.filter { known.contains($0.key) } votronicSolarESPStates = votronicSolarESPStates.filter { known.contains($0.key) }
// Geräte, die nur auf Anforderung verbunden werden, zeigen bis dahin // Geräte, die nur auf Anforderung verbunden werden, zeigen bis dahin
// ihren zuletzt gestellten Stand. // ihren zuletzt gestellten Stand.
@@ -552,9 +556,9 @@ final class BluetoothManager: NSObject {
levelSessions[peripheralID] = nil levelSessions[peripheralID] = nil
disconnect(peripheralID) disconnect(peripheralID)
} }
for (peripheralID, session) in solarSessions where !wanted.contains(peripheralID) { for (peripheralID, session) in votronicSolarESPSessions where !wanted.contains(peripheralID) {
session.stop() session.stop()
solarSessions[peripheralID] = nil votronicSolarESPSessions[peripheralID] = nil
disconnect(peripheralID) disconnect(peripheralID)
} }
// Einstellungen an bestehende Sitzungen weiterreichen. // Einstellungen an bestehende Sitzungen weiterreichen.
@@ -602,11 +606,11 @@ final class BluetoothManager: NSObject {
discoveryFlushTimer?.cancel(); discoveryFlushTimer = nil discoveryFlushTimer?.cancel(); discoveryFlushTimer = nil
//for (_, session) in bmsSessions { session.stop() } //for (_, session) in bmsSessions { session.stop() }
for (_, session) in levelSessions { session.stop() } for (_, session) in levelSessions { session.stop() }
for (_, session) in solarSessions { session.stop() } for (_, session) in votronicSolarESPSessions { session.stop() }
for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) } for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) }
//bmsSessions.removeAll() //bmsSessions.removeAll()
levelSessions.removeAll() levelSessions.removeAll()
solarSessions.removeAll() votronicSolarESPSessions.removeAll()
connectedPeripherals.removeAll() connectedPeripherals.removeAll()
connectedSince.removeAll() connectedSince.removeAll()
//pendingControls.removeAll() //pendingControls.removeAll()
@@ -802,8 +806,8 @@ final class BluetoothManager: NSObject {
if let name, !name.isEmpty { entry.name = name } if let name, !name.isEmpty { entry.name = name }
let services = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? [] let services = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
entry.isLevelSensor = services.contains(LevelSession.serviceUUID) entry.isLevelSensor = services.contains(LevelSession.serviceUUID)
entry.isSolarSensor = services.contains(SolarSession.serviceUUID) entry.isVotronicSolarESPSensor = services.contains(VotronicSolarESPSession.serviceUUID)
entry.looksLikeSupported = entry.isLevelSensor || entry.isSolarSensor entry.looksLikeSupported = entry.isLevelSensor || entry.isVotronicSolarESPSensor
pendingDiscoveries[peripheral.identifier] = entry pendingDiscoveries[peripheral.identifier] = entry
} }
/*private func updateDiscovery(peripheral: CBPeripheral, /*private func updateDiscovery(peripheral: CBPeripheral,
@@ -927,7 +931,17 @@ extension BluetoothManager: CBCentralManagerDelegate {
queue: queue, queue: queue,
onUpdate: { [weak self] snapshot in self?.record(snapshot) }, onUpdate: { [weak self] snapshot in self?.record(snapshot) },
onStateChange: { [weak self] state in onStateChange: { [weak self] state in
self?.publish { self?.linkStates[device.id] = state } self?.publish {
self?.linkStates[device.id] = state
if state == .live {
// Verbindung wieder da: ein anstehendes Ende
// verwerfen oder eine zwischenzeitlich doch schon
// beendete Live Activity wieder aufnehmen.
self?.activityManager?.handleReconnect(
deviceID: device.id,
state: self?.levelStates[device.id] ?? LevelState())
}
}
}, },
onLevelState: { [weak self] state in onLevelState: { [weak self] state in
self?.publish { self?.publish {
@@ -951,7 +965,7 @@ extension BluetoothManager: CBCentralManagerDelegate {
// Bewusst kein Draht zu `activityManager`: Der Solarertrag soll // Bewusst kein Draht zu `activityManager`: Der Solarertrag soll
// nicht in der Live Activity/CarPlay auftauchen, die ist dem // nicht in der Live Activity/CarPlay auftauchen, die ist dem
// Neigungsmesser vorbehalten. // Neigungsmesser vorbehalten.
let session = SolarSession( let session = VotronicSolarESPSession(
deviceID: device.id, deviceID: device.id,
peripheral: peripheral, peripheral: peripheral,
queue: queue, queue: queue,
@@ -959,11 +973,11 @@ extension BluetoothManager: CBCentralManagerDelegate {
onStateChange: { [weak self] state in onStateChange: { [weak self] state in
self?.publish { self?.linkStates[device.id] = state } self?.publish { self?.linkStates[device.id] = state }
}, },
onSolarState: { [weak self] state in onVotronicSolarESPState: { [weak self] state in
self?.publish { self?.solarStates[device.id] = state } self?.publish { self?.votronicSolarESPStates[device.id] = state }
} }
) )
solarSessions[peripheral.identifier] = session votronicSolarESPSessions[peripheral.identifier] = session
session.start() session.start()
return return
} }
@@ -1015,15 +1029,21 @@ extension BluetoothManager: CBCentralManagerDelegate {
//bmsSessions[peripheral.identifier] = nil //bmsSessions[peripheral.identifier] = nil
levelSessions[peripheral.identifier]?.handleDisconnect() levelSessions[peripheral.identifier]?.handleDisconnect()
levelSessions[peripheral.identifier] = nil levelSessions[peripheral.identifier] = nil
solarSessions[peripheral.identifier]?.handleDisconnect() votronicSolarESPSessions[peripheral.identifier]?.handleDisconnect()
solarSessions[peripheral.identifier] = nil votronicSolarESPSessions[peripheral.identifier] = nil
connectedPeripherals[peripheral.identifier] = nil connectedPeripherals[peripheral.identifier] = nil
let lifetime = connectedSince.removeValue(forKey: peripheral.identifier) let lifetime = connectedSince.removeValue(forKey: peripheral.identifier)
.map { Date().timeIntervalSince($0) } ?? 0 .map { Date().timeIntervalSince($0) } ?? 0
if let device = managed[peripheral.identifier] { if let device = managed[peripheral.identifier] {
publish { self.linkStates[device.id] = .searching } let deviceName = store.devices.first { $0.id == device.id }?.name ?? ""
publish {
self.linkStates[device.id] = .searching
// Erst nach einer Gnadenfrist wirklich beenden sonst
// flackerte die Live Activity bei jedem kurzen Funkloch.
self.activityManager?.handleDisconnect(deviceID: device.id, deviceName: deviceName)
}
if lifetime >= stableConnection { if lifetime >= stableConnection {
// Die Verbindung stand und ist weggefallen - im Fahrzeug der // Die Verbindung stand und ist weggefallen - im Fahrzeug der
// Normalfall. Kurz durchatmen, dann wieder ran, sonst wäre das // Normalfall. Kurz durchatmen, dann wieder ran, sonst wäre das
@@ -15,6 +15,18 @@ final class LevelActivityManager {
private(set) var trackedDeviceID: UUID? private(set) var trackedDeviceID: UUID?
@ObservationIgnored private var activity: Activity<LevelActivityAttributes>? @ObservationIgnored private var activity: Activity<LevelActivityAttributes>?
/// Wie lange nach einem Verbindungsabbruch gewartet wird, bevor die
/// Aktivität wirklich endet. Verbindungen fallen im Fahrzeug regelmässig
/// kurz weg ohne diese Gnadenfrist flackerte die Anzeige bei jedem
/// kurzen Funkloch aus und wieder ein.
static let disconnectGrace: TimeInterval = 12
@ObservationIgnored private var pendingEnd: Task<Void, Never>?
/// Gerät, dessen Aktivität wegen einer länger anhaltenden Trennung
/// tatsächlich beendet wurde bei der nächsten Wiederverbindung wird sie
/// automatisch neu gestartet, damit das kein bewusster Nutzer-Stop war.
@ObservationIgnored private var deviceToResume: (id: UUID, name: String)?
/// Ob gerade irgendeine Aktivität läuft die App muss dafür im /// Ob gerade irgendeine Aktivität läuft die App muss dafür im
/// Hintergrund weiter nach dem Neigungsmesser funken, sonst friert die /// Hintergrund weiter nach dem Neigungsmesser funken, sonst friert die
/// Anzeige beim ersten Sperren des Bildschirms ein. /// Anzeige beim ersten Sperren des Bildschirms ein.
@@ -25,8 +37,11 @@ final class LevelActivityManager {
} }
func start(deviceID: UUID, deviceName: String, state: LevelState) { func start(deviceID: UUID, deviceName: String, state: LevelState) {
pendingEnd?.cancel()
pendingEnd = nil
deviceToResume = nil
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
end() endActivity()
let attributes = LevelActivityAttributes(deviceName: deviceName) let attributes = LevelActivityAttributes(deviceName: deviceName)
let content = ActivityContent(state: Self.contentState(from: state), staleDate: nil) let content = ActivityContent(state: Self.contentState(from: state), staleDate: nil)
do { do {
@@ -46,7 +61,45 @@ final class LevelActivityManager {
Task { await activity.update(content) } Task { await activity.update(content) }
} }
/// Bewusstes Beenden durch den Nutzer anders als bei einem
/// Verbindungsabbruch soll das bei der nächsten Verbindung nicht
/// automatisch wieder aufleben.
func end() { func end() {
pendingEnd?.cancel()
pendingEnd = nil
deviceToResume = nil
endActivity()
}
/// BLE-Verbindung weg: nicht sofort beenden, sondern erst nach einer
/// kurzen Gnadenfrist meldet sich das Gerät vorher zurück
/// (`handleReconnect`), passiert gar nichts.
func handleDisconnect(deviceID: UUID, deviceName: String) {
guard trackedDeviceID == deviceID, activity != nil, pendingEnd == nil else { return }
pendingEnd = Task { [weak self] in
try? await Task.sleep(for: .seconds(Self.disconnectGrace))
guard let self, !Task.isCancelled, self.trackedDeviceID == deviceID else { return }
self.deviceToResume = (deviceID, deviceName)
self.endActivity()
self.pendingEnd = nil
}
}
/// BLE-Verbindung wieder da: ein noch anstehendes Ende verwerfen, oder
/// falls die Gnadenfrist schon abgelaufen und die Aktivität wirklich
/// beendet war sie mit dem letzten bekannten Stand neu starten.
func handleReconnect(deviceID: UUID, state: LevelState) {
if pendingEnd != nil, trackedDeviceID == deviceID {
pendingEnd?.cancel()
pendingEnd = nil
return
}
guard let resume = deviceToResume, resume.id == deviceID else { return }
deviceToResume = nil
start(deviceID: deviceID, deviceName: resume.name, state: state)
}
private func endActivity() {
guard let activity else { return } guard let activity else { return }
trackedDeviceID = nil trackedDeviceID = nil
self.activity = nil self.activity = nil
+20 -8
View File
@@ -3,18 +3,30 @@ import Foundation
/// Füllt die App mit erfundenen Messwerten, damit sich die Ansichten ohne /// Füllt die App mit erfundenen Messwerten, damit sich die Ansichten ohne
/// Fahrzeug und ohne Bluetooth prüfen lassen. /// Fahrzeug und ohne Bluetooth prüfen lassen.
/// ///
/// Nur in Debug-Builds und nur, wenn beim Start `CAMPER_DEMO=1` gesetzt ist: /// Nur in Debug-Builds, auf zwei Wegen einzuschalten:
/// ///
/// xcrun simctl launch --terminate-running-process \ /// * Umgebungsvariable `CAMPER_DEMO=1` beim Start praktisch im Simulator:
/// booted de.fritob.CamperMonitor ///
/// # mit: SIMCTL_CHILD_CAMPER_DEMO=1 davor /// xcrun simctl launch --terminate-running-process \
/// booted de.fritob.CamperMonitor
/// # mit: SIMCTL_CHILD_CAMPER_DEMO=1 davor
///
/// * Schalter in den Einstellungen (`SettingsView`) der einzige Weg auf
/// einem lokal installierten Build ohne Xcode-Verbindung, da sich dort
/// keine Umgebungsvariable setzen lässt. Wirkt erst nach einem Neustart der
/// App, weil `DeviceStore` und `BluetoothManager` den Stand nur beim Start
/// lesen.
/// ///
/// Im normalen Betrieb wird hiervon nichts ausgeführt. /// Im normalen Betrieb wird hiervon nichts ausgeführt.
enum DemoData { enum DemoData {
/// Schlüssel für den Einstellungen-Schalter, siehe `SettingsView`.
static let enabledKey = "demoModeEnabled"
static var isEnabled: Bool { static var isEnabled: Bool {
#if DEBUG #if DEBUG
return ProcessInfo.processInfo.environment["CAMPER_DEMO"] == "1" if ProcessInfo.processInfo.environment["CAMPER_DEMO"] == "1" { return true }
return UserDefaults.standard.bool(forKey: enabledKey)
#else #else
return false return false
#endif #endif
@@ -69,8 +81,8 @@ enum DemoData {
} }
/// Mittags, gute Sonne. /// Mittags, gute Sonne.
static var solarState: SolarState { static var votronicSolarESPState: VotronicSolarESPState {
var state = SolarState() var state = VotronicSolarESPState()
state.batteryVoltage = 13.9 state.batteryVoltage = 13.9
state.pvVoltage = 19.4 state.pvVoltage = 19.4
state.pvCurrent = 6.2 state.pvCurrent = 6.2
@@ -84,7 +96,7 @@ enum DemoData {
/// Ein paar Stunden Verlauf je Kanal, damit die Grafik im Demo-Modus /// Ein paar Stunden Verlauf je Kanal, damit die Grafik im Demo-Modus
/// sofort etwas zeigt statt erst nach ein paar Minuten Laufzeit. /// sofort etwas zeigt statt erst nach ein paar Minuten Laufzeit.
static func solarHistory() -> DeviceHistory { static func votronicSolarESPHistory() -> DeviceHistory {
let now = Date() let now = Date()
func series(around value: Double, noise: Double) -> [HistorySample] { func series(around value: Double, noise: Double) -> [HistorySample] {
(0..<120).reversed().map { step in (0..<120).reversed().map { step in
+3 -3
View File
@@ -195,7 +195,7 @@ private struct ConfigureDeviceView: View {
} }
} else*/ if discovery.isLevelSensor { } else*/ if discovery.isLevelSensor {
role = .leveling role = .leveling
} else if discovery.isSolarSensor { } else if discovery.isVotronicSolarESPSensor {
role = .solar role = .solar
} /*else if let name = discovery.name?.lowercased(), } /*else if let name = discovery.name?.lowercased(),
["alpicool", "icecube", "ice cube", "fridge", "cool"].contains(where: name.contains) { ["alpicool", "icecube", "ice cube", "fridge", "cool"].contains(where: name.contains) {
@@ -208,8 +208,8 @@ private struct ConfigureDeviceView: View {
// ohnehin darüber unter "Gefunden als". // ohnehin darüber unter "Gefunden als".
if discovery.isLevelSensor { if discovery.isLevelSensor {
name = "Nivellierung" name = "Nivellierung"
} else if discovery.isSolarSensor { } else if discovery.isVotronicSolarESPSensor {
name = "Solarladeregler" name = role.title
} else if let advertised = discovery.name, advertised.count <= 20, } else if let advertised = discovery.name, advertised.count <= 20,
advertised.contains(" ") || advertised.rangeOfCharacter(from: .decimalDigits) == nil { advertised.contains(" ") || advertised.rangeOfCharacter(from: .decimalDigits) == nil {
name = advertised name = advertised
+130 -61
View File
@@ -11,6 +11,7 @@ struct AlignmentAssistantView: View {
@Environment(BluetoothManager.self) private var bluetooth @Environment(BluetoothManager.self) private var bluetooth
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.verticalSizeClass) private var verticalSizeClass
@State private var assistant = AlignmentAssistant() @State private var assistant = AlignmentAssistant()
@State private var didAnnounceTarget = false @State private var didAnnounceTarget = false
@@ -18,49 +19,18 @@ struct AlignmentAssistantView: View {
private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() } private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() }
/// iPhone im Querformat meldet eine kompakte Höhe das ist das
/// zuverlässige Signal dafür, nicht die Geräteausrichtung selbst.
private var isLandscape: Bool { verticalSizeClass == .compact }
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ScrollView { Group {
VStack(spacing: 24) { if isLandscape {
Picker("Darstellung", selection: $displayStyle) { landscapeLayout
ForEach(LevelDisplayStyle.allCases) { style in } else {
Text(style.title).tag(style) portraitLayout
}
}
.pickerStyle(.segmented)
.padding(.top, 8)
switch displayStyle {
case .bubble:
LevelBubble(pitch: state.pitch, roll: state.roll)
.frame(maxWidth: 320)
case .vehicle:
VehicleTiltView(pitch: state.pitch, roll: state.roll,
style: device.vehicleGraphicStyle)
}
if !isLive { disconnectedBanner }
adviceBanner
readings
if let best = assistant.best, let gain = assistant.improvementAtBest,
let seconds = assistant.timeSinceBest {
bestPointCard(best: best, gain: gain, seconds: seconds)
}
wedgeSection
Button("Neu beginnen", systemImage: "arrow.counterclockwise") {
assistant.reset()
didAnnounceTarget = false
}
.buttonStyle(.bordered)
.padding(.bottom, 24)
} }
.padding(.horizontal)
.frame(maxWidth: .infinity)
} }
.background(Color(.systemGroupedBackground)) .background(Color(.systemGroupedBackground))
.navigationTitle("Ausrichtungs-Assistent") .navigationTitle("Ausrichtungs-Assistent")
@@ -96,10 +66,94 @@ struct AlignmentAssistantView: View {
} }
} }
// MARK: - Layouts
private var portraitLayout: some View {
ScrollView {
VStack(spacing: 24) {
picker.padding(.top, 8)
display
if !isLive { disconnectedBanner }
adviceBanner
readings
if let best = assistant.best, let gain = assistant.improvementAtBest,
let seconds = assistant.timeSinceBest {
bestPointCard(best: best, gain: gain, seconds: seconds)
}
wedgeSection
resetButton
.padding(.bottom, 24)
}
.padding(.horizontal)
.frame(maxWidth: .infinity)
}
}
/// Anzeige links, alles zum Rangieren Nötige rechts ohne Scrollen, denn
/// im Querformat schaut man beiläufig hin, nicht in Ruhe. Die
/// Bestpunkt-Karte und der ausführliche Verbindungs-Hinweis bleiben dafür
/// dem Hochformat vorbehalten; die Keilhöhen bleiben in jedem Fall sichtbar.
private var landscapeLayout: some View {
HStack(alignment: .top, spacing: 16) {
VStack(spacing: 8) {
picker
display
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity)
VStack(spacing: 8) {
if !isLive { compactDisconnectedBanner }
adviceBanner
readings
wedgeSection
resetButton
}
.frame(maxWidth: .infinity)
}
.padding(12)
}
// MARK: - Bausteine // MARK: - Bausteine
private var isLive: Bool { bluetooth.linkStates[device.id] == .live } private var isLive: Bool { bluetooth.linkStates[device.id] == .live }
private var picker: some View {
Picker("Darstellung", selection: $displayStyle) {
ForEach(LevelDisplayStyle.allCases) { style in
Text(style.title).tag(style)
}
}
.pickerStyle(.segmented)
}
@ViewBuilder
private var display: some View {
switch displayStyle {
case .bubble:
LevelBubble(pitch: state.pitch, roll: state.roll)
.frame(maxWidth: isLandscape ? 220 : 320, maxHeight: isLandscape ? 160 : .infinity)
case .vehicle:
VehicleTiltView(pitch: state.pitch, roll: state.roll,
style: device.vehicleGraphicStyle, compact: isLandscape)
}
}
private var resetButton: some View {
Button("Neu beginnen", systemImage: "arrow.counterclockwise") {
assistant.reset()
didAnnounceTarget = false
}
.buttonStyle(.bordered)
}
/// Reisst die Verbindung beim Rangieren ab, stehen die Zahlen still. Ohne /// Reisst die Verbindung beim Rangieren ab, stehen die Zahlen still. Ohne
/// Hinweis sähe das aus, als hinge die App man rangiert dann nach einem /// Hinweis sähe das aus, als hinge die App man rangiert dann nach einem
/// Wert, der längst nicht mehr gilt. /// Wert, der längst nicht mehr gilt.
@@ -121,40 +175,54 @@ struct AlignmentAssistantView: View {
.background(Color.orange.opacity(0.15), in: .rect(cornerRadius: 16)) .background(Color.orange.opacity(0.15), in: .rect(cornerRadius: 16))
} }
/// Kurzform für Querformat: derselbe Hinweis in einer Zeile.
private var compactDisconnectedBanner: some View {
Label("Nicht verbunden Anzeige steht still", systemImage: "antenna.radiowaves.left.and.right.slash")
.font(.caption.weight(.medium))
.foregroundStyle(.orange)
.lineLimit(1)
.minimumScaleFactor(0.8)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(Color.orange.opacity(0.15), in: .rect(cornerRadius: 10))
}
private var adviceBanner: some View { private var adviceBanner: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: assistant.hasReachedTarget Image(systemName: assistant.hasReachedTarget
? "checkmark.circle.fill" : assistant.trend.symbol) ? "checkmark.circle.fill" : assistant.trend.symbol)
.font(.title) .font(isLandscape ? .title2 : .title)
.foregroundStyle(assistant.hasReachedTarget ? Color.green : Color.accentColor) .foregroundStyle(assistant.hasReachedTarget ? Color.green : Color.accentColor)
Text(assistant.advice) Text(assistant.advice)
.font(.title3.weight(.medium)) .font(isLandscape ? .subheadline.weight(.medium) : .title3.weight(.medium))
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
.padding() .padding(isLandscape ? 10 : 16)
.background(assistant.hasReachedTarget ? Color.green.opacity(0.15) .background(assistant.hasReachedTarget ? Color.green.opacity(0.15)
: Color(.secondarySystemGroupedBackground), : Color(.secondarySystemGroupedBackground),
in: .rect(cornerRadius: 16)) in: .rect(cornerRadius: 16))
} }
private var readings: some View { private var readings: some View {
HStack(spacing: 12) { HStack(spacing: isLandscape ? 8 : 12) {
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch)) reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
reading("Quer", LevelDirectionFormatting.rollTile(state.roll)) reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
reading("Gesamt", LevelDirectionFormatting.magnitude(assistant.current?.deviation)) if !isLandscape {
reading("Gesamt", LevelDirectionFormatting.magnitude(assistant.current?.deviation))
}
} }
} }
private func reading(_ title: String, _ text: String) -> some View { private func reading(_ title: String, _ text: String) -> some View {
VStack(spacing: 4) { VStack(spacing: isLandscape ? 1 : 4) {
Text(text) Text(text)
.font(.title2.weight(.semibold).monospacedDigit()) .font((isLandscape ? Font.callout : .title2).weight(.semibold).monospacedDigit())
Text(title) Text(title)
.font(.caption) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 12) .padding(.vertical, isLandscape ? 6 : 12)
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 12)) .background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 12))
} }
@@ -188,23 +256,25 @@ struct AlignmentAssistantView: View {
trackWidth: width, wheelbase: base) trackWidth: width, wheelbase: base)
}() }()
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: isLandscape ? 6 : 10) {
Label("Auffahrkeile", systemImage: "triangle.fill") Label("Auffahrkeile", systemImage: "triangle.fill")
.font(.headline) .font(isLandscape ? .subheadline.weight(.semibold) : .headline)
if profile?.trackWidth == nil || profile?.wheelbase == nil { if profile?.trackWidth == nil || profile?.wheelbase == nil {
Text("Für die Keilhöhe fehlen Spurweite und Radstand. Beides lässt " Text("Für die Keilhöhe fehlen Spurweite und Radstand. Beides lässt "
+ "sich beim Fahrzeug hinterlegen.") + "sich beim Fahrzeug hinterlegen.")
.font(.callout)
.foregroundStyle(.secondary)
} else if let lift, !lift.isNegligible {
WheelLiftPlan(lift: lift)
.frame(maxWidth: .infinity)
Text("Zentimeter unter das jeweilige Rad. Das höchststehende Rad "
+ "bleibt liegen, die übrigen werden auf seine Höhe gebracht.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else if let lift, !lift.isNegligible {
WheelLiftPlan(lift: lift, isCompact: isLandscape)
.frame(maxWidth: .infinity)
if !isLandscape {
Text("Zentimeter unter das jeweilige Rad. Das höchststehende Rad "
+ "bleibt liegen, die übrigen werden auf seine Höhe gebracht.")
.font(.caption)
.foregroundStyle(.secondary)
}
} else { } else {
Text("Keine Keile nötig.") Text("Keine Keile nötig.")
.font(.callout) .font(.callout)
@@ -212,7 +282,7 @@ struct AlignmentAssistantView: View {
} }
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding() .padding(isLandscape ? 10 : 16)
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16)) .background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
} }
@@ -230,4 +300,3 @@ struct AlignmentAssistantView: View {
#endif #endif
} }
} }
+1 -1
View File
@@ -106,7 +106,7 @@ struct DashboardView: View {
Label("Noch keine Geräte", systemImage: "antenna.radiowaves.left.and.right") Label("Noch keine Geräte", systemImage: "antenna.radiowaves.left.and.right")
} description: { } description: {
Text("Füge \(store.activeProfile.map { "\($0.name)" } ?? "diesem Fahrzeug") " Text("Füge \(store.activeProfile.map { "\($0.name)" } ?? "diesem Fahrzeug") "
+ "den Ladebooster, den Solarladeregler und das BMS hinzu.") + "den Ladebooster, den VotronicSolarESP und das BMS hinzu.")
} actions: { } actions: {
Button("Gerät suchen") { isAddingDevice = true } Button("Gerät suchen") { isAddingDevice = true }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
+120 -98
View File
@@ -7,6 +7,7 @@ struct DeviceDetailView: View {
@Environment(DeviceStore.self) private var store @Environment(DeviceStore.self) private var store
@Environment(BluetoothManager.self) private var bluetooth @Environment(BluetoothManager.self) private var bluetooth
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.verticalSizeClass) private var verticalSizeClass
@State private var editedName = "" @State private var editedName = ""
/// Nur zum Vergleich mit dem, was das Gerät sendet. Eingetragen wird der /// Nur zum Vergleich mit dem, was das Gerät sendet. Eingetragen wird der
@@ -38,6 +39,15 @@ struct DeviceDetailView: View {
/// Nur solange die App läuft: siehe `DeviceHistory`. /// Nur solange die App läuft: siehe `DeviceHistory`.
@State private var selectedHistoryMetricKey: String? @State private var selectedHistoryMetricKey: String?
/// iPhone im Querformat meldet eine kompakte Höhe das ist das
/// zuverlässige Signal dafür, nicht die Geräteausrichtung selbst.
private var isLandscape: Bool { verticalSizeClass == .compact }
/// Nur der Neigungsmesser bekommt die Querformat-Sonderbehandlung
/// (Verbindungsstatus ans Ende, Anzeige auf Bildschirmhöhe) andere
/// Sensoren behalten ihre bisherige Reihenfolge und Grösse.
private var showsCompactLevelLayout: Bool { isLandscape && currentDevice.role == .leveling }
private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] } private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] }
private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching } private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching }
private var deviceHistory: DeviceHistory { bluetooth.history[device.id] ?? [:] } private var deviceHistory: DeviceHistory { bluetooth.history[device.id] ?? [:] }
@@ -62,119 +72,131 @@ struct DeviceDetailView: View {
} }
var body: some View { var body: some View {
List { // Die Höhe der Libelle/Fahrzeug-Anzeige im Querformat richtet sich
statusSection // nach der tatsächlich verfügbaren Bildschirmhöhe, nicht nach einem
// if needsKeyAttention { keyPrompt } // festen Wert deshalb misst ein GeometryReader die Liste von aussen.
GeometryReader { geometry in
List {
// Im Querformat soll die Libelle/Fahrzeug-Ansicht sofort
// sichtbar sein, ohne erst am Verbindungsstatus
// vorbeizuscrollen der rutscht dort ganz ans Ende. Gilt
// nur für den Neigungsmesser, siehe `showsCompactLevelLayout`.
if !showsCompactLevelLayout { statusSection }
// if needsKeyAttention { keyPrompt }
// if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus { // if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus {
// FridgeControls(device: currentDevice, state: fridge) // FridgeControls(device: currentDevice, state: fridge)
// } // }
if currentDevice.role == .leveling { if currentDevice.role == .leveling {
LevelControls(device: currentDevice, LevelControls(device: currentDevice,
state: bluetooth.levelStates[device.id] ?? LevelState()) state: bluetooth.levelStates[device.id] ?? LevelState(),
} availableHeight: geometry.size.height)
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 let metric = selectedMetric, samples.count > 1 { if let snapshot, !snapshot.metrics.isEmpty {
Section { Section("Messwerte") {
if chartableMetrics.count > 1 { ForEach(snapshot.metrics) { metric in
Picker("Messgrösse", selection: Binding( LabeledContent(metric.label) {
get: { metric.key }, Text(metric.formattedWithUnit)
set: { selectedHistoryMetricKey = $0 } .monospacedDigit()
)) { .foregroundStyle(metric.value == nil ? .secondary : .primary)
ForEach(chartableMetrics) { candidate in
Text(candidate.label).tag(candidate.key)
} }
} }
.pickerStyle(.segmented)
.listRowInsets(EdgeInsets())
.padding(.horizontal)
.padding(.top, 4)
}
Chart(samples) { sample in
AreaMark(x: .value("Zeit", sample.time),
y: .value(metric.label, sample.value))
.foregroundStyle(.tint.opacity(0.15))
LineMark(x: .value("Zeit", sample.time),
y: .value(metric.label, sample.value))
.foregroundStyle(.tint)
.interpolationMethod(.monotone)
}
.chartYAxisLabel(metric.unit)
.frame(height: 180)
.padding(.vertical, 8)
} header: {
Text("Verlauf \(metric.label)")
} footer: {
Text("Nur für die laufende Sitzung wird beim Neustart der App verworfen.")
}
}
if let snapshot, !snapshot.cellVoltages.isEmpty {
cellSection(snapshot.cellVoltages)
}
if let snapshot, !snapshot.info.isEmpty {
Section("Gerät") {
ForEach(snapshot.info) { item in
LabeledContent(item.label, value: item.value)
} }
} }
}
/* if let snapshot, snapshot.temperatures.count > 1 { if let metric = selectedMetric, samples.count > 1 {
Section("Temperaturen") { Section {
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in if chartableMetrics.count > 1 {
LabeledContent("Fühler \(index + 1)") { Picker("Messgrösse", selection: Binding(
Text(String(format: "%.0f °C", value)).monospacedDigit() get: { metric.key },
set: { selectedHistoryMetricKey = $0 }
)) {
ForEach(chartableMetrics) { candidate in
Text(candidate.label).tag(candidate.key)
}
}
.pickerStyle(.segmented)
.listRowInsets(EdgeInsets())
.padding(.horizontal)
.padding(.top, 4)
}
Chart(samples) { sample in
AreaMark(x: .value("Zeit", sample.time),
y: .value(metric.label, sample.value))
.foregroundStyle(.tint.opacity(0.15))
LineMark(x: .value("Zeit", sample.time),
y: .value(metric.label, sample.value))
.foregroundStyle(.tint)
.interpolationMethod(.monotone)
}
.chartYAxisLabel(metric.unit)
.frame(height: 180)
.padding(.vertical, 8)
} header: {
Text("Verlauf \(metric.label)")
} footer: {
Text("Nur für die laufende Sitzung wird beim Neustart der App verworfen.")
}
}
if let snapshot, !snapshot.cellVoltages.isEmpty {
cellSection(snapshot.cellVoltages)
}
if let snapshot, !snapshot.info.isEmpty {
Section("Gerät") {
ForEach(snapshot.info) { item in
LabeledContent(item.label, value: item.value)
} }
} }
} }
}
if currentDevice.role.transport == .advertisement { if showsCompactLevelLayout { statusSection }
if showsTechnicalDetails { diagnosticsSection }
} else if showsTechnicalDetails {
bmsDiagnosticsSection
}
settingsSection*/ /* if let snapshot, snapshot.temperatures.count > 1 {
} Section("Temperaturen") {
.navigationTitle(currentDevice.name) ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in
.navigationBarTitleDisplayMode(.inline) LabeledContent("Fühler \(index + 1)") {
.onDisappear { Text(String(format: "%.0f °C", value)).monospacedDigit()
saveName() }
// Die Kühlbox wird nur verbunden, solange man sie ansieht jede }
// Verbindung meldet sich an ihrem Display an. }
//bluetooth.endSession(for: currentDevice) }
}
.onAppear { if currentDevice.role.transport == .advertisement {
editedName = currentDevice.name if showsTechnicalDetails { diagnosticsSection }
keyInput = store.victronKeyText(for: device.id) ?? "" } else if showsTechnicalDetails {
//bluetooth.beginSession(for: currentDevice) bmsDiagnosticsSection
} }
.confirmationDialog("Gerät entfernen?",
isPresented: $showDeleteConfirmation, settingsSection*/
titleVisibility: .visible) { }
Button("Entfernen", role: .destructive) { .navigationTitle(currentDevice.name)
store.remove(device) .navigationBarTitleDisplayMode(.inline)
bluetooth.refreshConfiguration() .onDisappear {
dismiss() saveName()
// Die Kühlbox wird nur verbunden, solange man sie ansieht jede
// Verbindung meldet sich an ihrem Display an.
//bluetooth.endSession(for: currentDevice)
}
.onAppear {
editedName = currentDevice.name
keyInput = store.victronKeyText(for: device.id) ?? ""
//bluetooth.beginSession(for: currentDevice)
}
.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.")
} }
} message: {
Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.")
} }
} }
+80 -29
View File
@@ -105,47 +105,76 @@ struct LevelBubble: View {
struct LevelControls: View { struct LevelControls: View {
let device: ConfiguredDevice let device: ConfiguredDevice
let state: LevelState let state: LevelState
/// Höhe der umgebenden Liste, von `DeviceDetailView` per `GeometryReader`
/// gemessen. Nur im Querformat gebraucht, um die Anzeige auf
/// Bildschirmhöhe zu bringen statt sie auf einen festen Wert zu kappen.
var availableHeight: CGFloat?
@Environment(BluetoothManager.self) private var bluetooth @Environment(BluetoothManager.self) private var bluetooth
@Environment(DeviceStore.self) private var store @Environment(DeviceStore.self) private var store
@Environment(LevelActivityManager.self) private var levelActivity @Environment(LevelActivityManager.self) private var levelActivity
@Environment(\.verticalSizeClass) private var verticalSizeClass
@State private var showAssistant = false @State private var showAssistant = false
@AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble @AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble
/// iPhone im Querformat meldet eine kompakte Höhe das ist das
/// zuverlässige Signal dafür, nicht die Geräteausrichtung selbst.
private var isLandscape: Bool { verticalSizeClass == .compact }
/// Verfügbare Höhe abzüglich grober Reserve für Listenränder und die
/// eigene vertikale Auffüllung genug, um praktisch den ganzen
/// Bildschirm zu nutzen, ohne über den unteren Rand hinauszuschiessen.
private var landscapeContentHeight: CGFloat {
max(150, (availableHeight ?? 350) - 56)
}
var body: some View { var body: some View {
Section { Section {
VStack(spacing: 16) { if isLandscape {
Picker("Darstellung", selection: $displayStyle) { HStack(alignment: .center, spacing: 20) {
ForEach(LevelDisplayStyle.allCases) { style in display
Text(style.title).tag(style) .frame(maxWidth: .infinity)
VStack(spacing: 12) {
picker
if let instruction = state.instruction {
Label(instruction,
systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
.font(.subheadline.weight(.semibold))
.foregroundStyle(state.isLevel ? Color.green : Color.primary)
.multilineTextAlignment(.center)
}
HStack(spacing: 16) {
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
}
}
.frame(maxWidth: .infinity)
}
.frame(height: landscapeContentHeight)
.padding(.vertical, 4)
} else {
VStack(spacing: 16) {
picker
display
if let instruction = state.instruction {
Label(instruction,
systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
.font(.headline)
.foregroundStyle(state.isLevel ? Color.green : Color.primary)
.multilineTextAlignment(.center)
}
HStack(spacing: 24) {
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
} }
} }
.pickerStyle(.segmented) .frame(maxWidth: .infinity)
.padding(.vertical, 8)
switch displayStyle {
case .bubble:
LevelBubble(pitch: state.pitch, roll: state.roll)
.frame(maxHeight: 220)
case .vehicle:
VehicleTiltView(pitch: state.pitch, roll: state.roll,
style: device.vehicleGraphicStyle)
}
if let instruction = state.instruction {
Label(instruction,
systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
.font(.headline)
.foregroundStyle(state.isLevel ? Color.green : Color.primary)
.multilineTextAlignment(.center)
}
HStack(spacing: 24) {
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
}
} }
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
} }
Section { Section {
@@ -220,6 +249,28 @@ struct LevelControls: View {
} }
} }
private var picker: some View {
Picker("Darstellung", selection: $displayStyle) {
ForEach(LevelDisplayStyle.allCases) { style in
Text(style.title).tag(style)
}
}
.pickerStyle(.segmented)
}
@ViewBuilder
private var display: some View {
switch displayStyle {
case .bubble:
LevelBubble(pitch: state.pitch, roll: state.roll)
.frame(maxHeight: isLandscape ? landscapeContentHeight : 220)
case .vehicle:
VehicleTiltView(pitch: state.pitch, roll: state.roll,
style: device.vehicleGraphicStyle, compact: isLandscape,
compactPanelHeight: max(60, landscapeContentHeight - 40))
}
}
private var isLive: Bool { bluetooth.linkStates[device.id] == .live } private var isLive: Bool { bluetooth.linkStates[device.id] == .live }
private var liveActivityBinding: Binding<Bool> { private var liveActivityBinding: Binding<Bool> {
+16
View File
@@ -4,12 +4,28 @@ import SwiftUI
/// betrifft, steht bei diesem Gerät. /// betrifft, steht bei diesem Gerät.
struct SettingsView: View { struct SettingsView: View {
@AppStorage(AppSettings.showDiagnosticsKey) private var showDiagnostics = false @AppStorage(AppSettings.showDiagnosticsKey) private var showDiagnostics = false
#if DEBUG
@AppStorage(DemoData.enabledKey) private var demoModeEnabled = false
#endif
@Environment(PhoneWatchLink.self) private var watch @Environment(PhoneWatchLink.self) private var watch
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
var body: some View { var body: some View {
NavigationStack { NavigationStack {
List { List {
#if DEBUG
Section {
Toggle("Demo-Modus", isOn: $demoModeEnabled)
} header: {
Text("Entwicklung")
} footer: {
Text("Füllt die App mit erfundenen Fahrzeugen und Messwerten, um "
+ "Ansichten ohne echte Sensoren zu prüfen. Wirkt erst nach "
+ "einem Neustart der App einmal beenden (im App-Umschalter "
+ "nach oben wischen) und wieder öffnen.")
}
#endif
Section { Section {
Toggle("Diagnose anzeigen", isOn: $showDiagnostics) Toggle("Diagnose anzeigen", isOn: $showDiagnostics)
} header: { } header: {
+34 -27
View File
@@ -16,31 +16,38 @@ struct VehicleTiltView: View {
let pitch: Double? let pitch: Double?
let roll: Double? let roll: Double?
var style: VehicleGraphicStyle = .vanster var style: VehicleGraphicStyle = .vanster
/// Für Querformat: beide Ansichten nebeneinander statt untereinander,
/// kleinere Schrift, ohne Überhöhungs-Hinweis muss ohne Scrollen in die
/// Bildschirmhöhe passen.
var compact = false
/// Bildhöhe je Panel im Querformat vom Aufrufer an die tatsächlich
/// verfügbare Bildschirmhöhe angepasst, statt fest verdrahtet.
var compactPanelHeight: CGFloat = 62
var body: some View { var body: some View {
VStack(spacing: 20) { if compact {
tiltPanel(image: style.sideImageName, HStack(alignment: .top, spacing: 16) {
angle: pitch, tiltPanel(image: style.sideImageName, angle: pitch, title: "Längs",
title: "Längs", lowerLabel: "Front", upperLabel: "Heck", aspect: style.sideAspect)
lowerLabel: "Front", tiltPanel(image: style.rearImageName, angle: roll, title: "Quer",
upperLabel: "Heck", lowerLabel: "links", upperLabel: "rechts", aspect: style.rearAspect)
aspect: style.sideAspect) }
} else {
VStack(spacing: 20) {
tiltPanel(image: style.sideImageName, angle: pitch, title: "Längs",
lowerLabel: "Front", upperLabel: "Heck", aspect: style.sideAspect)
tiltPanel(image: style.rearImageName, angle: roll, title: "Quer",
lowerLabel: "links", upperLabel: "rechts", aspect: style.rearAspect)
tiltPanel(image: style.rearImageName, // Ohne diesen Hinweis nähme man den Bildwinkel für den echten.
angle: roll, Text(String(format: "Neigung %.0f-fach überhöht dargestellt "
title: "Quer", + "sonst wäre sie kaum zu erkennen. Die Gradzahlen sind echt.",
lowerLabel: "links", VehicleTilt.exaggeration))
upperLabel: "rechts", .font(.caption2)
aspect: style.rearAspect) .foregroundStyle(.secondary)
.multilineTextAlignment(.center)
// Ohne diesen Hinweis nähme man den Bildwinkel für den echten. .frame(maxWidth: .infinity)
Text(String(format: "Neigung %.0f-fach überhöht dargestellt " }
+ "sonst wäre sie kaum zu erkennen. Die Gradzahlen sind echt.",
VehicleTilt.exaggeration))
.font(.caption2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
} }
} }
@@ -50,13 +57,13 @@ struct VehicleTiltView: View {
lowerLabel: String, lowerLabel: String,
upperLabel: String, upperLabel: String,
aspect: Double) -> some View { aspect: Double) -> some View {
VStack(spacing: 8) { VStack(spacing: compact ? 4 : 8) {
HStack { HStack {
Text(title) Text(title)
.font(.subheadline.weight(.medium)) .font(compact ? .caption2.weight(.medium) : .subheadline.weight(.medium))
Spacer() Spacer()
Text(angle.map { String(format: "%.1f°", $0) } ?? "") Text(angle.map { String(format: "%.1f°", $0) } ?? "")
.font(.subheadline.weight(.semibold).monospacedDigit()) .font((compact ? Font.caption2 : .subheadline).weight(.semibold).monospacedDigit())
.foregroundStyle(VehicleTilt.colour(for: angle)) .foregroundStyle(VehicleTilt.colour(for: angle))
} }
@@ -76,14 +83,14 @@ struct VehicleTiltView: View {
.animation(.spring(duration: 0.4), value: angle) .animation(.spring(duration: 0.4), value: angle)
.opacity(angle == nil ? 0.3 : 1) .opacity(angle == nil ? 0.3 : 1)
} }
.frame(height: 110) .frame(height: compact ? compactPanelHeight : 110)
HStack { HStack {
Text(lowerLabel) Text(lowerLabel)
Spacer() Spacer()
Text(upperLabel) Text(upperLabel)
} }
.font(.caption2) .font(compact ? .system(size: 9) : .caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
@@ -1,13 +1,13 @@
import Foundation import Foundation
/// Solarladeregler VanAlign Solar" zweiter ESP32 im Fahrzeug, liest einen /// VotronicSolarESP zweiter ESP32 im Fahrzeug, liest einen
/// Votronic-Solarladeregler aus und stellt die Werte über einen eigenen /// Votronic-Solarladeregler aus und stellt die Werte über einen eigenen
/// BLE-Dienst bereit. Siehe `firmware/vanalign/esp32_ble_solar.yaml`. /// BLE-Dienst bereit. Siehe `firmware/vanalign/esp32_ble_solar.yaml`.
/// ///
/// Wie beim Neigungsmesser: kein Rahmenprotokoll, jede Messgrösse liegt in /// Wie beim Neigungsmesser: kein Rahmenprotokoll, jede Messgrösse liegt in
/// einer eigenen Charakteristik, alle sind reine Lesewerte, der Client fragt /// einer eigenen Charakteristik, alle sind reine Lesewerte, der Client fragt
/// sie im Takt ab. /// sie im Takt ab.
enum VanAlignSolarProtocol { enum VotronicSolarESPProtocol {
/// Wird vom Gerät beworben, das Gerät ist darüber auffindbar. /// Wird vom Gerät beworben, das Gerät ist darüber auffindbar.
static let serviceUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A001" static let serviceUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A001"
@@ -6,25 +6,25 @@ import Foundation
/// Einfacher noch als `LevelSession`: die Firmware bietet für keine /// Einfacher noch als `LevelSession`: die Firmware bietet für keine
/// Charakteristik `notify` an (siehe `esp32_ble_solar.yaml`), es wird also /// Charakteristik `notify` an (siehe `esp32_ble_solar.yaml`), es wird also
/// immer im Takt abgefragt statt abonniert. /// immer im Takt abgefragt statt abonniert.
final class SolarSession: NSObject { final class VotronicSolarESPSession: NSObject {
static let serviceUUID = CBUUID(string: VanAlignSolarProtocol.serviceUUID) static let serviceUUID = CBUUID(string: VotronicSolarESPProtocol.serviceUUID)
private static let batteryVoltageUUID = CBUUID(string: VanAlignSolarProtocol.batteryVoltageUUID) private static let batteryVoltageUUID = CBUUID(string: VotronicSolarESPProtocol.batteryVoltageUUID)
private static let pvVoltageUUID = CBUUID(string: VanAlignSolarProtocol.pvVoltageUUID) private static let pvVoltageUUID = CBUUID(string: VotronicSolarESPProtocol.pvVoltageUUID)
private static let pvCurrentUUID = CBUUID(string: VanAlignSolarProtocol.pvCurrentUUID) private static let pvCurrentUUID = CBUUID(string: VotronicSolarESPProtocol.pvCurrentUUID)
private static let pvPowerUUID = CBUUID(string: VanAlignSolarProtocol.pvPowerUUID) private static let pvPowerUUID = CBUUID(string: VotronicSolarESPProtocol.pvPowerUUID)
private static let controllerTempUUID = CBUUID(string: VanAlignSolarProtocol.controllerTempUUID) private static let controllerTempUUID = CBUUID(string: VotronicSolarESPProtocol.controllerTempUUID)
private static let statusFlagsUUID = CBUUID(string: VanAlignSolarProtocol.statusFlagsUUID) private static let statusFlagsUUID = CBUUID(string: VotronicSolarESPProtocol.statusFlagsUUID)
let deviceID: UUID let deviceID: UUID
private let queue: DispatchQueue private let queue: DispatchQueue
private let peripheral: CBPeripheral private let peripheral: CBPeripheral
private let onUpdate: (DeviceSnapshot) -> Void private let onUpdate: (DeviceSnapshot) -> Void
private let onStateChange: (DeviceLinkState) -> Void private let onStateChange: (DeviceLinkState) -> Void
private let onSolarState: (SolarState) -> Void private let onVotronicSolarESPState: (VotronicSolarESPState) -> Void
private var characteristics: [CBUUID: CBCharacteristic] = [:] private var characteristics: [CBUUID: CBCharacteristic] = [:]
private var state = SolarState() private var state = VotronicSolarESPState()
private var pollTimer: DispatchSourceTimer? private var pollTimer: DispatchSourceTimer?
/// Reicht für einen Solarregler, dessen Werte sich über Sekunden ändern /// Reicht für einen Solarregler, dessen Werte sich über Sekunden ändern
@@ -36,13 +36,13 @@ final class SolarSession: NSObject {
queue: DispatchQueue, queue: DispatchQueue,
onUpdate: @escaping (DeviceSnapshot) -> Void, onUpdate: @escaping (DeviceSnapshot) -> Void,
onStateChange: @escaping (DeviceLinkState) -> Void, onStateChange: @escaping (DeviceLinkState) -> Void,
onSolarState: @escaping (SolarState) -> Void) { onVotronicSolarESPState: @escaping (VotronicSolarESPState) -> Void) {
self.deviceID = deviceID self.deviceID = deviceID
self.queue = queue self.queue = queue
self.peripheral = peripheral self.peripheral = peripheral
self.onUpdate = onUpdate self.onUpdate = onUpdate
self.onStateChange = onStateChange self.onStateChange = onStateChange
self.onSolarState = onSolarState self.onVotronicSolarESPState = onVotronicSolarESPState
super.init() super.init()
peripheral.delegate = self peripheral.delegate = self
} }
@@ -87,14 +87,14 @@ final class SolarSession: NSObject {
private func publish() { private func publish() {
guard state.hasReading else { return } guard state.hasReading else { return }
onStateChange(.live) onStateChange(.live)
onSolarState(state) onVotronicSolarESPState(state)
onUpdate(state.snapshot(deviceID: deviceID, rssi: nil)) onUpdate(state.snapshot(deviceID: deviceID, rssi: nil))
} }
} }
// MARK: - CBPeripheralDelegate // MARK: - CBPeripheralDelegate
extension SolarSession: CBPeripheralDelegate { extension VotronicSolarESPSession: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
if let error { if let error {
@@ -137,17 +137,17 @@ extension SolarSession: CBPeripheralDelegate {
switch characteristic.uuid { switch characteristic.uuid {
case Self.batteryVoltageUUID: case Self.batteryVoltageUUID:
state.batteryVoltage = VanAlignSolarProtocol.float(from: value) state.batteryVoltage = VotronicSolarESPProtocol.float(from: value)
case Self.pvVoltageUUID: case Self.pvVoltageUUID:
state.pvVoltage = VanAlignSolarProtocol.float(from: value) state.pvVoltage = VotronicSolarESPProtocol.float(from: value)
case Self.pvCurrentUUID: case Self.pvCurrentUUID:
state.pvCurrent = VanAlignSolarProtocol.float(from: value) state.pvCurrent = VotronicSolarESPProtocol.float(from: value)
case Self.pvPowerUUID: case Self.pvPowerUUID:
state.pvPower = VanAlignSolarProtocol.float(from: value) state.pvPower = VotronicSolarESPProtocol.float(from: value)
case Self.controllerTempUUID: case Self.controllerTempUUID:
state.controllerTemperature = VanAlignSolarProtocol.float(from: value) state.controllerTemperature = VotronicSolarESPProtocol.float(from: value)
case Self.statusFlagsUUID: case Self.statusFlagsUUID:
guard let flags = VanAlignSolarProtocol.statusFlags(from: value) else { return } guard let flags = VotronicSolarESPProtocol.statusFlags(from: value) else { return }
state.isBatteryCharging = flags.isBatteryCharging state.isBatteryCharging = flags.isBatteryCharging
state.isBatteryDischarging = flags.isBatteryDischarging state.isBatteryDischarging = flags.isBatteryDischarging
state.isControllerActive = flags.isControllerActive state.isControllerActive = flags.isControllerActive
+1 -1
View File
@@ -19,7 +19,7 @@ enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
//case .bms: return "Batterie / BMS" //case .bms: return "Batterie / BMS"
//case .fridge: return "Kühlbox" //case .fridge: return "Kühlbox"
case .leveling: return "Nivellierung" case .leveling: return "Nivellierung"
case .solar: return "Solarladeregler" case .solar: return "VotronicSolarESP"
} }
} }
@@ -1,7 +1,7 @@
import Foundation import Foundation
/// Zustand des Solarladereglers. /// Zustand des Solarladereglers.
struct SolarState: Equatable, Codable, Sendable { struct VotronicSolarESPState: Equatable, Codable, Sendable {
var batteryVoltage: Double? var batteryVoltage: Double?
var pvVoltage: Double? var pvVoltage: Double?
var pvCurrent: Double? var pvCurrent: Double?
@@ -19,6 +19,20 @@ private struct LevelIcon: View {
} }
} }
/// Muss mit `LevelState.levelTolerance` übereinstimmen.
private let levelTolerance: Double = 0.5
/// Grün bis Toleranz, orange bis 2°, sonst rot dieselbe Skala für Blase
/// und Text, wie in der App (`VehicleTilt.colour`). Hier dupliziert, weil
/// die Extension `Shared/` nicht mitkompiliert (siehe
/// `LevelActivityAttributes.swift`).
private func levelColour(forDeviation deviation: Double?) -> Color {
guard let deviation else { return .white.opacity(0.4) }
if deviation <= levelTolerance { return .green }
if deviation <= 2 { return .orange }
return .red
}
/// Verkleinerte Libelle für Sperrbildschirm/CarPlay und die erweiterte /// Verkleinerte Libelle für Sperrbildschirm/CarPlay und die erweiterte
/// Dynamic Island dieselbe Optik wie `LevelBubble` in der App, aber ohne /// Dynamic Island dieselbe Optik wie `LevelBubble` in der App, aber ohne
/// deren Abhängigkeit auf `Shared/Models/LevelState.swift`, das in der /// deren Abhängigkeit auf `Shared/Models/LevelState.swift`, das in der
@@ -31,17 +45,12 @@ private struct LevelBubbleGlyph: View {
/// Bis zu welcher Neigung die Blase ausschlägt, wie in der App-Libelle. /// Bis zu welcher Neigung die Blase ausschlägt, wie in der App-Libelle.
private let range: Double = 6 private let range: Double = 6
/// Muss mit `LevelState.levelTolerance` übereinstimmen.
private let tolerance: Double = 0.5
private var hasReading: Bool { pitch != nil || roll != nil } private var hasReading: Bool { pitch != nil || roll != nil }
private var bubbleColor: Color { private var bubbleColor: Color {
guard let pitch, let roll else { return .white.opacity(0.4) } guard let pitch, let roll else { return .white.opacity(0.4) }
let deviation = max(abs(pitch), abs(roll)) return levelColour(forDeviation: max(abs(pitch), abs(roll)))
if deviation <= tolerance { return .green }
if deviation <= 2 { return .orange }
return .red
} }
var body: some View { var body: some View {
@@ -50,7 +59,7 @@ private struct LevelBubbleGlyph: View {
let travel = radius - bubble / 2 - 3 let travel = radius - bubble / 2 - 3
let offsetX = clamped(roll) / range * travel let offsetX = clamped(roll) / range * travel
let offsetY = clamped(pitch) / range * travel let offsetY = clamped(pitch) / range * travel
let toleranceRadius = max(tolerance / range * travel, bubble * 0.55) let toleranceRadius = max(levelTolerance / range * travel, bubble * 0.55)
ZStack { ZStack {
Circle() Circle()
@@ -137,28 +146,67 @@ private struct LevelActivityContent: View {
} }
/// Kompaktform für CarPlay-Dashboard und Smart Stack der Uhr: Libelle plus /// Kompaktform für CarPlay-Dashboard und Smart Stack der Uhr: Libelle plus
/// eine Textzeile, ohne die längere Zwei-Zeilen-Aufschlüsselung des /// ein Blick-Symbol statt eines ganzen Satzes zum Lesen im Vorbeifahren.
/// Sperrbildschirms. ///
/// Gezeigt wird nur, welche Seite höher steht: Buchstabe (Längsneigung),
/// Pfeil, Buchstabe (Querneigung) z.B. "H L" für "Heck und links stehen
/// höher". Ein einzelner Pfeil in der Mitte reicht, weil ohnehin nur die
/// höher stehende Seite benannt wird ("höher" ist implizit "nach oben").
private struct CompactLevelView: View { private struct CompactLevelView: View {
let attributes: LevelActivityAttributes let attributes: LevelActivityAttributes
let state: LevelActivityAttributes.ContentState let state: LevelActivityAttributes.ContentState
private var hasReading: Bool { state.pitch != nil || state.roll != nil }
/// "H"/"F", nur wenn ausserhalb der Toleranz.
private var pitchLetter: String? {
guard let pitch = state.pitch, abs(pitch) > levelTolerance else { return nil }
return pitch >= 0 ? "H" : "F"
}
/// "R"/"L", nur wenn ausserhalb der Toleranz.
private var rollLetter: String? {
guard let roll = state.roll, abs(roll) > levelTolerance else { return nil }
return roll >= 0 ? "R" : "L"
}
/// Dieselbe Skala wie die Blase, über die grössere der beiden Neigungen.
private var statusColor: Color {
guard let pitch = state.pitch, let roll = state.roll else { return .white.opacity(0.4) }
return levelColour(forDeviation: max(abs(pitch), abs(roll)))
}
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 14) {
LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel, diameter: 40) LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel, diameter: 40)
Text(state.instruction Group {
?? (state.pitch == nil && state.roll == nil ? "Keine Messwerte" : attributes.deviceName)) if !hasReading {
.font(.callout.weight(.semibold)) Text("")
.foregroundStyle(state.isLevel ? .green : .white) } else if state.isLevel {
.lineLimit(1) Image(systemName: "checkmark.circle.fill")
.minimumScaleFactor(0.8) } else {
HStack(spacing: 6) {
if let pitchLetter { Text(pitchLetter) }
Image(systemName: "arrow.up")
if let rollLetter { Text(rollLetter) }
}
}
}
// Skalierbarer Textstil statt fester Punktgrösse: CarPlay-
// Bildschirme unterscheiden sich in Grösse, die exakten Masse
// eines Displays sind aber nicht abfragbar. `minimumScaleFactor`
// lässt die Schrift so gross wie möglich, aber so klein wie
// nötig werden, um in den vom System zugeteilten Platz zu passen.
.font(.title2.weight(.bold))
.minimumScaleFactor(0.5)
.lineLimit(1)
.foregroundStyle(statusColor)
Spacer(minLength: 0) Spacer(minLength: 0)
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 8) .padding(.vertical, 8)
.foregroundStyle(.white)
} }
} }
@@ -167,20 +215,17 @@ private struct LockScreenLevelView: View {
let attributes: LevelActivityAttributes let attributes: LevelActivityAttributes
let state: LevelActivityAttributes.ContentState let state: LevelActivityAttributes.ContentState
/// Muss mit `LevelState.levelTolerance` übereinstimmen.
private let tolerance: Double = 0.5
/// Längszeile, z.B. "Heck steht höher 1.8°" nur wenn ausserhalb der /// Längszeile, z.B. "Heck steht höher 1.8°" nur wenn ausserhalb der
/// Toleranz, genau wie bei `LevelState.instruction` in der App. /// Toleranz, genau wie bei `LevelState.instruction` in der App.
private var pitchLine: String? { private var pitchLine: String? {
guard let pitch = state.pitch, abs(pitch) > tolerance else { return nil } guard let pitch = state.pitch, abs(pitch) > levelTolerance else { return nil }
let label = pitch >= 0 ? "Heck steht höher" : "Front steht höher" let label = pitch >= 0 ? "Heck steht höher" : "Front steht höher"
return "\(label) \(LevelDirectionFormatting.magnitude(pitch))" return "\(label) \(LevelDirectionFormatting.magnitude(pitch))"
} }
/// Querzeile, z.B. "links steht höher 0.9°". /// Querzeile, z.B. "links steht höher 0.9°".
private var rollLine: String? { private var rollLine: String? {
guard let roll = state.roll, abs(roll) > tolerance else { return nil } guard let roll = state.roll, abs(roll) > levelTolerance else { return nil }
let label = roll >= 0 ? "rechts steht höher" : "links steht höher" let label = roll >= 0 ? "rechts steht höher" : "links steht höher"
return "\(label) \(LevelDirectionFormatting.magnitude(roll))" return "\(label) \(LevelDirectionFormatting.magnitude(roll))"
} }