Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
644c4c7767 | ||
|
|
7fc9442aeb | ||
|
|
8d975d2c4d | ||
|
|
21f5d8de62 | ||
|
|
7dcb564cbf | ||
|
|
004e64e933 | ||
|
|
362a62cd77 | ||
|
|
97e3d040a9 |
|
After Width: | Height: | Size: 341 KiB |
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "CaliforniaSide.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : { "template-rendering-intent" : "template" }
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "VehicleRear.png",
|
||||
"filename" : "VansterRear.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 130 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "VehicleSide.png",
|
||||
"filename" : "VansterSide.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
|
Before Width: | Height: | Size: 253 KiB After Width: | Height: | Size: 253 KiB |
@@ -18,7 +18,7 @@ struct Discovery: Identifiable, Hashable {
|
||||
/// Der Neigungsmesser bewirbt seinen Dienst, ist also sicher erkennbar.
|
||||
var isLevelSensor = false
|
||||
/// Der Solarladeregler bewirbt seinen Dienst ebenso.
|
||||
var isSolarSensor = false
|
||||
var isVotronicSolarESPSensor = false
|
||||
|
||||
//var isVictron: Bool { victronRecordType != nil }
|
||||
|
||||
@@ -33,7 +33,7 @@ struct Discovery: Identifiable, Hashable {
|
||||
|
||||
var subtitle: String {
|
||||
if isLevelSensor { return "VanAlign Neigungsmesser" }
|
||||
if isSolarSensor { return "VanAlign Solarladeregler" }
|
||||
if isVotronicSolarESPSensor { return "VotronicSolarESP" }
|
||||
return "Bluetooth-Gerät"
|
||||
}
|
||||
/*var subtitle: String {
|
||||
@@ -164,7 +164,7 @@ final class BluetoothManager: NSObject {
|
||||
//private(set) var bmsDiagnostics: [UUID: BMSDiagnostics] = [:]
|
||||
//private(set) var fridgeStates: [UUID: AlpicoolState] = [:]
|
||||
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 bluetoothStatusText = "Bluetooth wird gestartet…"
|
||||
|
||||
@@ -201,7 +201,7 @@ final class BluetoothManager: NSObject {
|
||||
|
||||
//private var bmsSessions: [UUID: BMSSession] = [:]
|
||||
private var levelSessions: [UUID: LevelSession] = [:]
|
||||
private var solarSessions: [UUID: SolarSession] = [:]
|
||||
private var votronicSolarESPSessions: [UUID: VotronicSolarESPSession] = [:]
|
||||
private var connectedPeripherals: [UUID: CBPeripheral] = [:]
|
||||
private var reconnectTimer: DispatchSourceTimer?
|
||||
|
||||
@@ -280,11 +280,15 @@ final class BluetoothManager: NSObject {
|
||||
bluetoothStatusText = "Demo-Modus"
|
||||
//fridgeStates[DemoData.fridge.id] = DemoData.fridgeState
|
||||
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
|
||||
record(DemoData.solarState.snapshot(deviceID: DemoData.solar.id, rssi: -58))
|
||||
history[DemoData.solar.id] = DemoData.solarHistory()
|
||||
record(DemoData.votronicSolarESPState.snapshot(deviceID: DemoData.solar.id, rssi: -58))
|
||||
history[DemoData.solar.id] = DemoData.votronicSolarESPHistory()
|
||||
/* for snapshot in DemoData.snapshots() {
|
||||
snapshots[snapshot.deviceID] = snapshot
|
||||
linkStates[snapshot.deviceID] = .live
|
||||
@@ -342,7 +346,7 @@ final class BluetoothManager: NSObject {
|
||||
//bmsDiagnostics = bmsDiagnostics.filter { known.contains($0.key) }
|
||||
//fridgeStates = fridgeStates.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
|
||||
// ihren zuletzt gestellten Stand.
|
||||
@@ -552,9 +556,9 @@ final class BluetoothManager: NSObject {
|
||||
levelSessions[peripheralID] = nil
|
||||
disconnect(peripheralID)
|
||||
}
|
||||
for (peripheralID, session) in solarSessions where !wanted.contains(peripheralID) {
|
||||
for (peripheralID, session) in votronicSolarESPSessions where !wanted.contains(peripheralID) {
|
||||
session.stop()
|
||||
solarSessions[peripheralID] = nil
|
||||
votronicSolarESPSessions[peripheralID] = nil
|
||||
disconnect(peripheralID)
|
||||
}
|
||||
// Einstellungen an bestehende Sitzungen weiterreichen.
|
||||
@@ -602,11 +606,11 @@ final class BluetoothManager: NSObject {
|
||||
discoveryFlushTimer?.cancel(); discoveryFlushTimer = nil
|
||||
//for (_, session) in bmsSessions { 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) }
|
||||
//bmsSessions.removeAll()
|
||||
levelSessions.removeAll()
|
||||
solarSessions.removeAll()
|
||||
votronicSolarESPSessions.removeAll()
|
||||
connectedPeripherals.removeAll()
|
||||
connectedSince.removeAll()
|
||||
//pendingControls.removeAll()
|
||||
@@ -802,8 +806,8 @@ final class BluetoothManager: NSObject {
|
||||
if let name, !name.isEmpty { entry.name = name }
|
||||
let services = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
|
||||
entry.isLevelSensor = services.contains(LevelSession.serviceUUID)
|
||||
entry.isSolarSensor = services.contains(SolarSession.serviceUUID)
|
||||
entry.looksLikeSupported = entry.isLevelSensor || entry.isSolarSensor
|
||||
entry.isVotronicSolarESPSensor = services.contains(VotronicSolarESPSession.serviceUUID)
|
||||
entry.looksLikeSupported = entry.isLevelSensor || entry.isVotronicSolarESPSensor
|
||||
pendingDiscoveries[peripheral.identifier] = entry
|
||||
}
|
||||
/*private func updateDiscovery(peripheral: CBPeripheral,
|
||||
@@ -927,7 +931,17 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
||||
queue: queue,
|
||||
onUpdate: { [weak self] snapshot in self?.record(snapshot) },
|
||||
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
|
||||
self?.publish {
|
||||
@@ -951,7 +965,7 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
||||
// Bewusst kein Draht zu `activityManager`: Der Solarertrag soll
|
||||
// nicht in der Live Activity/CarPlay auftauchen, die ist dem
|
||||
// Neigungsmesser vorbehalten.
|
||||
let session = SolarSession(
|
||||
let session = VotronicSolarESPSession(
|
||||
deviceID: device.id,
|
||||
peripheral: peripheral,
|
||||
queue: queue,
|
||||
@@ -959,11 +973,11 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
||||
onStateChange: { [weak self] state in
|
||||
self?.publish { self?.linkStates[device.id] = state }
|
||||
},
|
||||
onSolarState: { [weak self] state in
|
||||
self?.publish { self?.solarStates[device.id] = state }
|
||||
onVotronicSolarESPState: { [weak self] state in
|
||||
self?.publish { self?.votronicSolarESPStates[device.id] = state }
|
||||
}
|
||||
)
|
||||
solarSessions[peripheral.identifier] = session
|
||||
votronicSolarESPSessions[peripheral.identifier] = session
|
||||
session.start()
|
||||
return
|
||||
}
|
||||
@@ -1015,15 +1029,21 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
||||
//bmsSessions[peripheral.identifier] = nil
|
||||
levelSessions[peripheral.identifier]?.handleDisconnect()
|
||||
levelSessions[peripheral.identifier] = nil
|
||||
solarSessions[peripheral.identifier]?.handleDisconnect()
|
||||
solarSessions[peripheral.identifier] = nil
|
||||
votronicSolarESPSessions[peripheral.identifier]?.handleDisconnect()
|
||||
votronicSolarESPSessions[peripheral.identifier] = nil
|
||||
connectedPeripherals[peripheral.identifier] = nil
|
||||
|
||||
let lifetime = connectedSince.removeValue(forKey: peripheral.identifier)
|
||||
.map { Date().timeIntervalSince($0) } ?? 0
|
||||
|
||||
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 {
|
||||
// Die Verbindung stand und ist weggefallen - im Fahrzeug der
|
||||
// Normalfall. Kurz durchatmen, dann wieder ran, sonst wäre das
|
||||
|
||||
@@ -5,14 +5,28 @@ import Observation
|
||||
/// Startet, aktualisiert und beendet die Live Activity des Neigungsmessers.
|
||||
///
|
||||
/// Es läuft höchstens eine Aktivität gleichzeitig – mehr als einen
|
||||
/// Neigungsmesser gibt es im aktiven Profil ohnehin nicht. Seit iOS 17 zeigt
|
||||
/// Neigungsmesser gibt es im aktiven Profil ohnehin nicht. Seit iOS 26 zeigt
|
||||
/// CarPlay eine laufende Live Activity automatisch im Dashboard an; ein
|
||||
/// eigenes CarPlay-App-Target braucht es dafür nicht.
|
||||
/// eigenes CarPlay-App-Target braucht es dafür nicht – die Inhalte kommen
|
||||
/// aber nicht von der Sperrbildschirm-Ansicht, sondern von der
|
||||
/// `.small`-Aktivitätsfamilie (siehe `VanAligneiOSWidgetLiveActivity`).
|
||||
@Observable
|
||||
final class LevelActivityManager {
|
||||
private(set) var trackedDeviceID: UUID?
|
||||
@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
|
||||
/// Hintergrund weiter nach dem Neigungsmesser funken, sonst friert die
|
||||
/// Anzeige beim ersten Sperren des Bildschirms ein.
|
||||
@@ -23,8 +37,11 @@ final class LevelActivityManager {
|
||||
}
|
||||
|
||||
func start(deviceID: UUID, deviceName: String, state: LevelState) {
|
||||
pendingEnd?.cancel()
|
||||
pendingEnd = nil
|
||||
deviceToResume = nil
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
|
||||
end()
|
||||
endActivity()
|
||||
let attributes = LevelActivityAttributes(deviceName: deviceName)
|
||||
let content = ActivityContent(state: Self.contentState(from: state), staleDate: nil)
|
||||
do {
|
||||
@@ -44,7 +61,45 @@ final class LevelActivityManager {
|
||||
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() {
|
||||
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 }
|
||||
trackedDeviceID = nil
|
||||
self.activity = nil
|
||||
|
||||
@@ -3,18 +3,30 @@ import Foundation
|
||||
/// Füllt die App mit erfundenen Messwerten, damit sich die Ansichten ohne
|
||||
/// 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:
|
||||
///
|
||||
/// * Umgebungsvariable `CAMPER_DEMO=1` beim Start – praktisch im Simulator:
|
||||
///
|
||||
/// 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.
|
||||
enum DemoData {
|
||||
|
||||
/// Schlüssel für den Einstellungen-Schalter, siehe `SettingsView`.
|
||||
static let enabledKey = "demoModeEnabled"
|
||||
|
||||
static var isEnabled: Bool {
|
||||
#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
|
||||
return false
|
||||
#endif
|
||||
@@ -69,8 +81,8 @@ enum DemoData {
|
||||
}
|
||||
|
||||
/// Mittags, gute Sonne.
|
||||
static var solarState: SolarState {
|
||||
var state = SolarState()
|
||||
static var votronicSolarESPState: VotronicSolarESPState {
|
||||
var state = VotronicSolarESPState()
|
||||
state.batteryVoltage = 13.9
|
||||
state.pvVoltage = 19.4
|
||||
state.pvCurrent = 6.2
|
||||
@@ -84,7 +96,7 @@ enum DemoData {
|
||||
|
||||
/// Ein paar Stunden Verlauf je Kanal, damit die Grafik im Demo-Modus
|
||||
/// sofort etwas zeigt statt erst nach ein paar Minuten Laufzeit.
|
||||
static func solarHistory() -> DeviceHistory {
|
||||
static func votronicSolarESPHistory() -> DeviceHistory {
|
||||
let now = Date()
|
||||
func series(around value: Double, noise: Double) -> [HistorySample] {
|
||||
(0..<120).reversed().map { step in
|
||||
|
||||
@@ -195,7 +195,7 @@ private struct ConfigureDeviceView: View {
|
||||
}
|
||||
} else*/ if discovery.isLevelSensor {
|
||||
role = .leveling
|
||||
} else if discovery.isSolarSensor {
|
||||
} else if discovery.isVotronicSolarESPSensor {
|
||||
role = .solar
|
||||
} /*else if let name = discovery.name?.lowercased(),
|
||||
["alpicool", "icecube", "ice cube", "fridge", "cool"].contains(where: name.contains) {
|
||||
@@ -208,8 +208,8 @@ private struct ConfigureDeviceView: View {
|
||||
// ohnehin darüber unter "Gefunden als".
|
||||
if discovery.isLevelSensor {
|
||||
name = "Nivellierung"
|
||||
} else if discovery.isSolarSensor {
|
||||
name = "Solarladeregler"
|
||||
} else if discovery.isVotronicSolarESPSensor {
|
||||
name = role.title
|
||||
} else if let advertised = discovery.name, advertised.count <= 20,
|
||||
advertised.contains(" ") || advertised.rangeOfCharacter(from: .decimalDigits) == nil {
|
||||
name = advertised
|
||||
|
||||
@@ -11,6 +11,7 @@ struct AlignmentAssistantView: View {
|
||||
|
||||
@Environment(BluetoothManager.self) private var bluetooth
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
|
||||
@State private var assistant = AlignmentAssistant()
|
||||
@State private var didAnnounceTarget = false
|
||||
@@ -18,49 +19,19 @@ struct AlignmentAssistantView: View {
|
||||
|
||||
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 {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
Picker("Darstellung", selection: $displayStyle) {
|
||||
ForEach(LevelDisplayStyle.allCases) { style in
|
||||
Text(style.title).tag(style)
|
||||
Group {
|
||||
if isLandscape {
|
||||
landscapeLayout
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
|
||||
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))
|
||||
.navigationTitle("Ausrichtungs-Assistent")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -95,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
|
||||
|
||||
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
|
||||
/// Hinweis sähe das aus, als hinge die App – man rangiert dann nach einem
|
||||
/// Wert, der längst nicht mehr gilt.
|
||||
@@ -120,40 +175,54 @@ struct AlignmentAssistantView: View {
|
||||
.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 {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: assistant.hasReachedTarget
|
||||
? "checkmark.circle.fill" : assistant.trend.symbol)
|
||||
.font(.title)
|
||||
.font(isLandscape ? .title2 : .title)
|
||||
.foregroundStyle(assistant.hasReachedTarget ? Color.green : Color.accentColor)
|
||||
Text(assistant.advice)
|
||||
.font(.title3.weight(.medium))
|
||||
.font(isLandscape ? .subheadline.weight(.medium) : .title3.weight(.medium))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding()
|
||||
.padding(isLandscape ? 10 : 16)
|
||||
.background(assistant.hasReachedTarget ? Color.green.opacity(0.15)
|
||||
: Color(.secondarySystemGroupedBackground),
|
||||
in: .rect(cornerRadius: 16))
|
||||
}
|
||||
|
||||
private var readings: some View {
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: isLandscape ? 8 : 12) {
|
||||
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
|
||||
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
|
||||
if !isLandscape {
|
||||
reading("Gesamt", LevelDirectionFormatting.magnitude(assistant.current?.deviation))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reading(_ title: String, _ text: String) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
VStack(spacing: isLandscape ? 1 : 4) {
|
||||
Text(text)
|
||||
.font(.title2.weight(.semibold).monospacedDigit())
|
||||
.font((isLandscape ? Font.callout : .title2).weight(.semibold).monospacedDigit())
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.padding(.vertical, isLandscape ? 6 : 12)
|
||||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 12))
|
||||
}
|
||||
|
||||
@@ -187,23 +256,25 @@ struct AlignmentAssistantView: View {
|
||||
trackWidth: width, wheelbase: base)
|
||||
}()
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: isLandscape ? 6 : 10) {
|
||||
Label("Auffahrkeile", systemImage: "triangle.fill")
|
||||
.font(.headline)
|
||||
.font(isLandscape ? .subheadline.weight(.semibold) : .headline)
|
||||
|
||||
if profile?.trackWidth == nil || profile?.wheelbase == nil {
|
||||
Text("Für die Keilhöhe fehlen Spurweite und Radstand. Beides lässt "
|
||||
+ "sich beim Fahrzeug hinterlegen.")
|
||||
.font(.callout)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if let lift, !lift.isNegligible {
|
||||
WheelLiftPlan(lift: lift)
|
||||
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 {
|
||||
Text("Keine Keile nötig.")
|
||||
.font(.callout)
|
||||
@@ -211,7 +282,7 @@ struct AlignmentAssistantView: View {
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.padding(isLandscape ? 10 : 16)
|
||||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
|
||||
}
|
||||
|
||||
@@ -229,4 +300,3 @@ struct AlignmentAssistantView: View {
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ struct DashboardView: View {
|
||||
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.")
|
||||
+ "den Ladebooster, den VotronicSolarESP und das BMS hinzu.")
|
||||
} actions: {
|
||||
Button("Gerät suchen") { isAddingDevice = true }
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
@@ -7,6 +7,7 @@ struct DeviceDetailView: View {
|
||||
@Environment(DeviceStore.self) private var store
|
||||
@Environment(BluetoothManager.self) private var bluetooth
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
|
||||
@State private var editedName = ""
|
||||
/// 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`.
|
||||
@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 linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching }
|
||||
private var deviceHistory: DeviceHistory { bluetooth.history[device.id] ?? [:] }
|
||||
@@ -62,8 +72,16 @@ struct DeviceDetailView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// Die Höhe der Libelle/Fahrzeug-Anzeige im Querformat richtet sich
|
||||
// nach der tatsächlich verfügbaren Bildschirmhöhe, nicht nach einem
|
||||
// festen Wert – deshalb misst ein GeometryReader die Liste von aussen.
|
||||
GeometryReader { geometry in
|
||||
List {
|
||||
statusSection
|
||||
// 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 {
|
||||
@@ -72,7 +90,8 @@ struct DeviceDetailView: View {
|
||||
|
||||
if currentDevice.role == .leveling {
|
||||
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 {
|
||||
@@ -134,6 +153,8 @@ struct DeviceDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if showsCompactLevelLayout { statusSection }
|
||||
|
||||
/* if let snapshot, snapshot.temperatures.count > 1 {
|
||||
Section("Temperaturen") {
|
||||
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in
|
||||
@@ -177,6 +198,7 @@ struct DeviceDetailView: View {
|
||||
Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Abschnitte
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ struct LevelSetupView: View {
|
||||
store.devices.first { $0.id == device.id } ?? device
|
||||
}
|
||||
|
||||
private let graphicColumns = [GridItem(.adaptive(minimum: 76), spacing: 12)]
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
@@ -80,6 +82,44 @@ struct LevelSetupView: View {
|
||||
} footer: {
|
||||
Text(calibrationHint)
|
||||
}
|
||||
|
||||
Section {
|
||||
LazyVGrid(columns: graphicColumns, spacing: 12) {
|
||||
ForEach(VehicleGraphicStyle.allCases) { style in
|
||||
Button {
|
||||
var updated = currentDevice
|
||||
updated.vehicleGraphicStyle = style
|
||||
store.update(updated)
|
||||
} label: {
|
||||
VStack(spacing: 4) {
|
||||
Image(style.sideImageName)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.padding(10)
|
||||
.frame(width: 60, height: 60)
|
||||
.background(currentDevice.vehicleGraphicStyle == style
|
||||
? Color.accentColor.opacity(0.18)
|
||||
: Color.secondary.opacity(0.08),
|
||||
in: .rect(cornerRadius: 12))
|
||||
.foregroundStyle(currentDevice.vehicleGraphicStyle == style
|
||||
? Color.accentColor : Color.primary)
|
||||
Text(style.title)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(currentDevice.vehicleGraphicStyle == style
|
||||
? Color.accentColor : Color.secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(style.title)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
} header: {
|
||||
Text("Fahrzeuggrafik")
|
||||
} footer: {
|
||||
Text("Wird in der Fahrzeug-Darstellung des Neigungsmessers verwendet. "
|
||||
+ "Weitere Grafiken folgen.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Neigungsmesser einrichten")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
|
||||
@@ -105,30 +105,59 @@ struct LevelBubble: View {
|
||||
struct LevelControls: View {
|
||||
let device: ConfiguredDevice
|
||||
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(DeviceStore.self) private var store
|
||||
@Environment(LevelActivityManager.self) private var levelActivity
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
@State private var showAssistant = false
|
||||
@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 {
|
||||
Section {
|
||||
VStack(spacing: 16) {
|
||||
Picker("Darstellung", selection: $displayStyle) {
|
||||
ForEach(LevelDisplayStyle.allCases) { style in
|
||||
Text(style.title).tag(style)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
if isLandscape {
|
||||
HStack(alignment: .center, spacing: 20) {
|
||||
display
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
switch displayStyle {
|
||||
case .bubble:
|
||||
LevelBubble(pitch: state.pitch, roll: state.roll)
|
||||
.frame(maxHeight: 220)
|
||||
case .vehicle:
|
||||
VehicleTiltView(pitch: state.pitch, roll: state.roll)
|
||||
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,
|
||||
@@ -146,6 +175,7 @@ struct LevelControls: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
@@ -219,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 liveActivityBinding: Binding<Bool> {
|
||||
|
||||
@@ -145,7 +145,7 @@ private struct ProfileEditView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var name = ""
|
||||
@State private var symbol = "box.truck"
|
||||
@State private var symbol = "suv.side"
|
||||
@State private var trackWidth = ""
|
||||
@State private var wheelbase = ""
|
||||
|
||||
|
||||
@@ -4,12 +4,28 @@ import SwiftUI
|
||||
/// betrifft, steht bei diesem Gerät.
|
||||
struct SettingsView: View {
|
||||
@AppStorage(AppSettings.showDiagnosticsKey) private var showDiagnostics = false
|
||||
#if DEBUG
|
||||
@AppStorage(DemoData.enabledKey) private var demoModeEnabled = false
|
||||
#endif
|
||||
@Environment(PhoneWatchLink.self) private var watch
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
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 {
|
||||
Toggle("Diagnose anzeigen", isOn: $showDiagnostics)
|
||||
} header: {
|
||||
|
||||
@@ -15,22 +15,29 @@ import SwiftUI
|
||||
struct VehicleTiltView: View {
|
||||
let pitch: Double?
|
||||
let roll: Double?
|
||||
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 {
|
||||
if compact {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: 20) {
|
||||
tiltPanel(image: "VehicleSide",
|
||||
angle: pitch,
|
||||
title: "Längs",
|
||||
lowerLabel: "Front",
|
||||
upperLabel: "Heck",
|
||||
aspect: VehicleTilt.sideAspect)
|
||||
|
||||
tiltPanel(image: "VehicleRear",
|
||||
angle: roll,
|
||||
title: "Quer",
|
||||
lowerLabel: "links",
|
||||
upperLabel: "rechts",
|
||||
aspect: VehicleTilt.rearAspect)
|
||||
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)
|
||||
|
||||
// Ohne diesen Hinweis nähme man den Bildwinkel für den echten.
|
||||
Text(String(format: "Neigung %.0f-fach überhöht dargestellt – "
|
||||
@@ -42,6 +49,7 @@ struct VehicleTiltView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func tiltPanel(image: String,
|
||||
angle: Double?,
|
||||
@@ -49,13 +57,13 @@ struct VehicleTiltView: View {
|
||||
lowerLabel: String,
|
||||
upperLabel: String,
|
||||
aspect: Double) -> some View {
|
||||
VStack(spacing: 8) {
|
||||
VStack(spacing: compact ? 4 : 8) {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.font(compact ? .caption2.weight(.medium) : .subheadline.weight(.medium))
|
||||
Spacer()
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -75,14 +83,14 @@ struct VehicleTiltView: View {
|
||||
.animation(.spring(duration: 0.4), value: angle)
|
||||
.opacity(angle == nil ? 0.3 : 1)
|
||||
}
|
||||
.frame(height: 110)
|
||||
.frame(height: compact ? compactPanelHeight : 110)
|
||||
|
||||
HStack {
|
||||
Text(lowerLabel)
|
||||
Spacer()
|
||||
Text(upperLabel)
|
||||
}
|
||||
.font(.caption2)
|
||||
.font(compact ? .system(size: 9) : .caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "VehicleRear.png",
|
||||
"filename" : "VansterRear.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "VehicleSide.png",
|
||||
"filename" : "VansterSide.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
@@ -14,9 +14,9 @@ struct WatchVehicleView: View {
|
||||
/// Die angeschriebenen Gradzahlen bleiben echt.
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
panel(image: "VehicleSide", angle: pitch, title: "Längs",
|
||||
panel(image: "VansterSide", angle: pitch, title: "Längs",
|
||||
lower: "Front", upper: "Heck", aspect: VehicleTilt.sideAspect)
|
||||
panel(image: "VehicleRear", angle: roll, title: "Quer",
|
||||
panel(image: "VansterRear", angle: roll, title: "Quer",
|
||||
lower: "links", upper: "rechts", aspect: VehicleTilt.rearAspect)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
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
|
||||
/// BLE-Dienst bereit. Siehe `firmware/vanalign/esp32_ble_solar.yaml`.
|
||||
///
|
||||
/// Wie beim Neigungsmesser: kein Rahmenprotokoll, jede Messgrösse liegt in
|
||||
/// einer eigenen Charakteristik, alle sind reine Lesewerte, der Client fragt
|
||||
/// sie im Takt ab.
|
||||
enum VanAlignSolarProtocol {
|
||||
enum VotronicSolarESPProtocol {
|
||||
|
||||
/// Wird vom Gerät beworben, das Gerät ist darüber auffindbar.
|
||||
static let serviceUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A001"
|
||||
@@ -6,25 +6,25 @@ import Foundation
|
||||
/// Einfacher noch als `LevelSession`: die Firmware bietet für keine
|
||||
/// Charakteristik `notify` an (siehe `esp32_ble_solar.yaml`), es wird also
|
||||
/// immer im Takt abgefragt statt abonniert.
|
||||
final class SolarSession: NSObject {
|
||||
final class VotronicSolarESPSession: NSObject {
|
||||
|
||||
static let serviceUUID = CBUUID(string: VanAlignSolarProtocol.serviceUUID)
|
||||
private static let batteryVoltageUUID = CBUUID(string: VanAlignSolarProtocol.batteryVoltageUUID)
|
||||
private static let pvVoltageUUID = CBUUID(string: VanAlignSolarProtocol.pvVoltageUUID)
|
||||
private static let pvCurrentUUID = CBUUID(string: VanAlignSolarProtocol.pvCurrentUUID)
|
||||
private static let pvPowerUUID = CBUUID(string: VanAlignSolarProtocol.pvPowerUUID)
|
||||
private static let controllerTempUUID = CBUUID(string: VanAlignSolarProtocol.controllerTempUUID)
|
||||
private static let statusFlagsUUID = CBUUID(string: VanAlignSolarProtocol.statusFlagsUUID)
|
||||
static let serviceUUID = CBUUID(string: VotronicSolarESPProtocol.serviceUUID)
|
||||
private static let batteryVoltageUUID = CBUUID(string: VotronicSolarESPProtocol.batteryVoltageUUID)
|
||||
private static let pvVoltageUUID = CBUUID(string: VotronicSolarESPProtocol.pvVoltageUUID)
|
||||
private static let pvCurrentUUID = CBUUID(string: VotronicSolarESPProtocol.pvCurrentUUID)
|
||||
private static let pvPowerUUID = CBUUID(string: VotronicSolarESPProtocol.pvPowerUUID)
|
||||
private static let controllerTempUUID = CBUUID(string: VotronicSolarESPProtocol.controllerTempUUID)
|
||||
private static let statusFlagsUUID = CBUUID(string: VotronicSolarESPProtocol.statusFlagsUUID)
|
||||
|
||||
let deviceID: UUID
|
||||
private let queue: DispatchQueue
|
||||
private let peripheral: CBPeripheral
|
||||
private let onUpdate: (DeviceSnapshot) -> Void
|
||||
private let onStateChange: (DeviceLinkState) -> Void
|
||||
private let onSolarState: (SolarState) -> Void
|
||||
private let onVotronicSolarESPState: (VotronicSolarESPState) -> Void
|
||||
|
||||
private var characteristics: [CBUUID: CBCharacteristic] = [:]
|
||||
private var state = SolarState()
|
||||
private var state = VotronicSolarESPState()
|
||||
private var pollTimer: DispatchSourceTimer?
|
||||
|
||||
/// Reicht für einen Solarregler, dessen Werte sich über Sekunden ändern –
|
||||
@@ -36,13 +36,13 @@ final class SolarSession: NSObject {
|
||||
queue: DispatchQueue,
|
||||
onUpdate: @escaping (DeviceSnapshot) -> Void,
|
||||
onStateChange: @escaping (DeviceLinkState) -> Void,
|
||||
onSolarState: @escaping (SolarState) -> Void) {
|
||||
onVotronicSolarESPState: @escaping (VotronicSolarESPState) -> Void) {
|
||||
self.deviceID = deviceID
|
||||
self.queue = queue
|
||||
self.peripheral = peripheral
|
||||
self.onUpdate = onUpdate
|
||||
self.onStateChange = onStateChange
|
||||
self.onSolarState = onSolarState
|
||||
self.onVotronicSolarESPState = onVotronicSolarESPState
|
||||
super.init()
|
||||
peripheral.delegate = self
|
||||
}
|
||||
@@ -87,14 +87,14 @@ final class SolarSession: NSObject {
|
||||
private func publish() {
|
||||
guard state.hasReading else { return }
|
||||
onStateChange(.live)
|
||||
onSolarState(state)
|
||||
onVotronicSolarESPState(state)
|
||||
onUpdate(state.snapshot(deviceID: deviceID, rssi: nil))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CBPeripheralDelegate
|
||||
|
||||
extension SolarSession: CBPeripheralDelegate {
|
||||
extension VotronicSolarESPSession: CBPeripheralDelegate {
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||||
if let error {
|
||||
@@ -137,17 +137,17 @@ extension SolarSession: CBPeripheralDelegate {
|
||||
|
||||
switch characteristic.uuid {
|
||||
case Self.batteryVoltageUUID:
|
||||
state.batteryVoltage = VanAlignSolarProtocol.float(from: value)
|
||||
state.batteryVoltage = VotronicSolarESPProtocol.float(from: value)
|
||||
case Self.pvVoltageUUID:
|
||||
state.pvVoltage = VanAlignSolarProtocol.float(from: value)
|
||||
state.pvVoltage = VotronicSolarESPProtocol.float(from: value)
|
||||
case Self.pvCurrentUUID:
|
||||
state.pvCurrent = VanAlignSolarProtocol.float(from: value)
|
||||
state.pvCurrent = VotronicSolarESPProtocol.float(from: value)
|
||||
case Self.pvPowerUUID:
|
||||
state.pvPower = VanAlignSolarProtocol.float(from: value)
|
||||
state.pvPower = VotronicSolarESPProtocol.float(from: value)
|
||||
case Self.controllerTempUUID:
|
||||
state.controllerTemperature = VanAlignSolarProtocol.float(from: value)
|
||||
state.controllerTemperature = VotronicSolarESPProtocol.float(from: value)
|
||||
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.isBatteryDischarging = flags.isBatteryDischarging
|
||||
state.isControllerActive = flags.isControllerActive
|
||||
@@ -19,7 +19,7 @@ enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
//case .bms: return "Batterie / BMS"
|
||||
//case .fridge: return "Kühlbox"
|
||||
case .leveling: return "Nivellierung"
|
||||
case .solar: return "Solarladeregler"
|
||||
case .solar: return "VotronicSolarESP"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,8 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
|
||||
//var fridgeZoneMode: FridgeZoneMode = .automatic
|
||||
/// Nur für den Neigungsmesser: wie er im Fahrzeug sitzt.
|
||||
var sensorOrientation = SensorOrientation.identity
|
||||
/// Nur für den Neigungsmesser: welche Fahrzeuggrafik die Fahrzeug-Ansicht zeigt.
|
||||
var vehicleGraphicStyle = VehicleGraphicStyle.vanster
|
||||
|
||||
init(id: UUID = UUID(),
|
||||
name: String,
|
||||
@@ -135,5 +137,7 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
|
||||
// ?? .automatic
|
||||
sensorOrientation = try container.decodeIfPresent(SensorOrientation.self,
|
||||
forKey: .sensorOrientation) ?? .identity
|
||||
vehicleGraphicStyle = try container.decodeIfPresent(VehicleGraphicStyle.self,
|
||||
forKey: .vehicleGraphicStyle) ?? .vanster
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ struct Profile: Identifiable, Codable, Hashable, Sendable {
|
||||
|
||||
init(id: UUID = UUID(),
|
||||
name: String,
|
||||
symbol: String = "box.truck",
|
||||
symbol: String = "suv.side",
|
||||
trackWidth: Double? = nil,
|
||||
wheelbase: Double? = nil) {
|
||||
self.id = id
|
||||
@@ -47,7 +47,6 @@ struct Profile: Identifiable, Codable, Hashable, Sendable {
|
||||
/// Alle Namen gegen NSImage(systemSymbolName:) geprüft – ein nicht
|
||||
/// existierendes Symbol lässt SwiftUI stillschweigend auf Text zurückfallen.
|
||||
static let symbols = [
|
||||
"box.truck", "truck.pickup.side", "bus", "bus.doubledecker",
|
||||
"car", "car.side", "tent", "sailboat", "house.lodge", "mountain.2",
|
||||
"car", "car.side", "suv.side", "truck.pickup.side", "box.truck", "bus",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
|
||||
/// Welche Silhouette die Fahrzeug-Ansicht des Neigungsmessers zeigt.
|
||||
///
|
||||
/// Jeder Fall ist ein zusammengehöriges Bilderset aus Seiten- und
|
||||
/// Heckansicht in Assets.xcassets, beide als Vorlage ("template")
|
||||
/// exportiert, damit `foregroundStyle` sie einfärben kann. Im Picker wird
|
||||
/// nur die Seitenansicht gezeigt.
|
||||
enum VehicleGraphicStyle: String, CaseIterable, Identifiable, Codable, Sendable {
|
||||
case vanster
|
||||
case california
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .vanster: return "Vanster"
|
||||
case .california: return "California"
|
||||
}
|
||||
}
|
||||
|
||||
var sideImageName: String {
|
||||
switch self {
|
||||
case .vanster: return "VansterSide"
|
||||
case .california: return "CaliforniaSide"
|
||||
}
|
||||
}
|
||||
|
||||
/// Für California liegt noch keine eigene Heckansicht vor, deshalb
|
||||
/// vorerst die von Vanster.
|
||||
var rearImageName: String {
|
||||
switch self {
|
||||
case .vanster, .california: return "VansterRear"
|
||||
}
|
||||
}
|
||||
|
||||
/// Seitenverhältnis der jeweiligen Bilder – ohne das würde eine Grafik
|
||||
/// mit anderen Proportionen in der gemeinsamen Bildhülle verzerrt oder
|
||||
/// ungewollt verkleinert erscheinen.
|
||||
var sideAspect: Double {
|
||||
switch self {
|
||||
case .vanster: return VehicleTilt.sideAspect
|
||||
case .california: return 1
|
||||
}
|
||||
}
|
||||
|
||||
var rearAspect: Double {
|
||||
switch self {
|
||||
case .vanster, .california: return VehicleTilt.rearAspect
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
/// Zustand des Solarladereglers.
|
||||
struct SolarState: Equatable, Codable, Sendable {
|
||||
struct VotronicSolarESPState: Equatable, Codable, Sendable {
|
||||
var batteryVoltage: Double?
|
||||
var pvVoltage: Double?
|
||||
var pvCurrent: Double?
|
||||
@@ -32,5 +32,11 @@ gefüllte Form, in der die Details erhalten bleiben.
|
||||
```bash
|
||||
swiftc -O -o /tmp/make-vehicle-art Tools/make-vehicle-art.swift
|
||||
sips --resampleHeight 600 heckansicht.png --out /tmp/rear.png
|
||||
/tmp/make-vehicle-art /tmp/rear.png CamperMonitor/Assets.xcassets/VehicleRear.imageset/VehicleRear.png
|
||||
/tmp/make-vehicle-art /tmp/rear.png CamperMonitor/Assets.xcassets/VansterRear.imageset/VansterRear.png
|
||||
```
|
||||
|
||||
Jedes Fahrzeug-Grafikset (siehe `VehicleGraphicStyle`) hat sein eigenes
|
||||
Bildpaar `<Name>Side`/`<Name>Rear`. Hat die Vorlage schon einen
|
||||
transparenten Hintergrund, geht die Quelldatei direkt ins Skript. Liegt sie
|
||||
auf weißem Grund, muss der Rand vorher weg – sonst wird die ganze Fläche
|
||||
mitgefärbt.
|
||||
|
||||
@@ -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
|
||||
/// Dynamic Island – dieselbe Optik wie `LevelBubble` in der App, aber ohne
|
||||
/// 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.
|
||||
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 bubbleColor: Color {
|
||||
guard let pitch, let roll else { return .white.opacity(0.4) }
|
||||
let deviation = max(abs(pitch), abs(roll))
|
||||
if deviation <= tolerance { return .green }
|
||||
if deviation <= 2 { return .orange }
|
||||
return .red
|
||||
return levelColour(forDeviation: max(abs(pitch), abs(roll)))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -50,7 +59,7 @@ private struct LevelBubbleGlyph: View {
|
||||
let travel = radius - bubble / 2 - 3
|
||||
let offsetX = clamped(roll) / 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 {
|
||||
Circle()
|
||||
@@ -81,7 +90,7 @@ private struct LevelBubbleGlyph: View {
|
||||
struct VanAligneiOSWidgetLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: LevelActivityAttributes.self) { context in
|
||||
LockScreenLevelView(attributes: context.attributes, state: context.state)
|
||||
LevelActivityContent(attributes: context.attributes, state: context.state)
|
||||
.activityBackgroundTint(Color(white: 0.08))
|
||||
.activitySystemActionForegroundColor(.white)
|
||||
|
||||
@@ -109,30 +118,114 @@ struct VanAligneiOSWidgetLiveActivity: Widget {
|
||||
}
|
||||
.keylineTint(context.state.isLevel ? .green : .orange)
|
||||
}
|
||||
.supplementalActivityFamilies([.small])
|
||||
}
|
||||
}
|
||||
|
||||
/// Sperrbildschirm- und Banner-Ansicht. Dieselbe Ansicht landet automatisch
|
||||
/// auch im CarPlay-Dashboard, sobald das iPhone verbunden ist – ein eigenes
|
||||
/// CarPlay-App-Target ist dafür nicht nötig.
|
||||
/// Wählt je nach Darstellungsgrösse zwischen Sperrbildschirm- und
|
||||
/// Kompaktansicht.
|
||||
///
|
||||
/// Ohne `.small`-Familie fällt CarPlay (wie die Smart Stack der Uhr) auf
|
||||
/// `compactLeading`/`compactTrailing` der Dynamic Island zurück – dort steht
|
||||
/// nur das generische Symbol und, weil `compactTrailing` leer bleibt, gar
|
||||
/// kein Text. Erst die `.small`-Familie liefert CarPlay Libelle und Text.
|
||||
private struct LevelActivityContent: View {
|
||||
let attributes: LevelActivityAttributes
|
||||
let state: LevelActivityAttributes.ContentState
|
||||
|
||||
@Environment(\.activityFamily) private var activityFamily
|
||||
|
||||
var body: some View {
|
||||
switch activityFamily {
|
||||
case .small:
|
||||
CompactLevelView(attributes: attributes, state: state)
|
||||
default:
|
||||
LockScreenLevelView(attributes: attributes, state: state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kompaktform für CarPlay-Dashboard und Smart Stack der Uhr: Libelle plus
|
||||
/// ein Blick-Symbol statt eines ganzen Satzes – zum Lesen im Vorbeifahren.
|
||||
///
|
||||
/// 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 {
|
||||
let attributes: LevelActivityAttributes
|
||||
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 {
|
||||
HStack(spacing: 14) {
|
||||
LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel, diameter: 40)
|
||||
|
||||
Group {
|
||||
if !hasReading {
|
||||
Text("–")
|
||||
} else if state.isLevel {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
} 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)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sperrbildschirm- und Banner-Ansicht.
|
||||
private struct LockScreenLevelView: View {
|
||||
let attributes: LevelActivityAttributes
|
||||
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
|
||||
/// Toleranz, genau wie bei `LevelState.instruction` in der App.
|
||||
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"
|
||||
return "\(label) \(LevelDirectionFormatting.magnitude(pitch))"
|
||||
}
|
||||
|
||||
/// Querzeile, z.B. "links steht höher 0.9°".
|
||||
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"
|
||||
return "\(label) \(LevelDirectionFormatting.magnitude(roll))"
|
||||
}
|
||||
|
||||