Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21f5d8de62 | ||
|
|
7dcb564cbf |
@@ -927,7 +927,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 {
|
||||
@@ -1023,7 +1033,13 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
||||
.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
|
||||
|
||||
@@ -15,6 +15,18 @@ 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.
|
||||
@@ -25,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 {
|
||||
@@ -46,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
|
||||
|
||||
@@ -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()
|
||||
@@ -137,28 +146,67 @@ private struct LevelActivityContent: View {
|
||||
}
|
||||
|
||||
/// Kompaktform für CarPlay-Dashboard und Smart Stack der Uhr: Libelle plus
|
||||
/// eine Textzeile, ohne die längere Zwei-Zeilen-Aufschlüsselung des
|
||||
/// Sperrbildschirms.
|
||||
/// 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: 12) {
|
||||
HStack(spacing: 14) {
|
||||
LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel, diameter: 40)
|
||||
|
||||
Text(state.instruction
|
||||
?? (state.pitch == nil && state.roll == nil ? "Keine Messwerte" : attributes.deviceName))
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(state.isLevel ? .green : .white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
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)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,20 +215,17 @@ 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))"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user