Compare commits

..
2 Commits
Author SHA1 Message Date
fototeddyandClaude Sonnet 5 0fdef337fc 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:08:40 +02:00
fototeddyandClaude Sonnet 5 1b06ac36d1 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 12:00:49 +02:00
3 changed files with 139 additions and 25 deletions
+18 -2
View File
@@ -897,7 +897,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 {
@@ -970,7 +980,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)
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)
.minimumScaleFactor(0.8)
.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))"
}