diff --git a/VanControl/LiveActivity/LevelActivityManager.swift b/VanControl/LiveActivity/LevelActivityManager.swift index e357787..aded908 100644 --- a/VanControl/LiveActivity/LevelActivityManager.swift +++ b/VanControl/LiveActivity/LevelActivityManager.swift @@ -21,6 +21,23 @@ final class LevelActivityManager { /// kurzen Funkloch aus und wieder ein. static let disconnectGrace: TimeInterval = 12 + /// Wie oft die Live Activity höchstens tatsächlich aktualisiert wird. + /// + /// Der Neigungsmesser liefert Werte deutlich öfter (abonniert bei jeder + /// Änderung, sonst alle 0,5 s abgefragt). Stösst man ActivityKit im + /// selben Takt an, drosselt iOS nach kurzer Zeit selbst immer stärker – + /// die Anzeige hinkt dann sichtbar hinterher, in CarPlay besonders + /// deutlich, weil das Dashboard ohnehin zurückhaltender aktualisiert als + /// Sperrbildschirm oder Dynamic Island. Ein fester Mindestabstand hält + /// die tatsächlichen Aktualisierungen unter dem, was das System duldet. + static let minimumUpdateInterval: TimeInterval = 1 + + @ObservationIgnored private var lastPushedAt: Date? + /// Ein während der Sperrfrist aufgelaufener Messwert, der nachgeholt + /// wird, sobald die Frist um ist – sonst bliebe die Anzeige bei + /// schnellen Änderungen auf einem Zwischenstand stehen. + @ObservationIgnored private var pendingUpdate: Task? + @ObservationIgnored private var pendingEnd: Task? /// Gerät, dessen Aktivität wegen einer länger anhaltenden Trennung /// tatsächlich beendet wurde – bei der nächsten Wiederverbindung wird sie @@ -40,6 +57,9 @@ final class LevelActivityManager { pendingEnd?.cancel() pendingEnd = nil deviceToResume = nil + pendingUpdate?.cancel() + pendingUpdate = nil + lastPushedAt = nil guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } endActivity() let attributes = LevelActivityAttributes(deviceName: deviceName) @@ -47,6 +67,7 @@ final class LevelActivityManager { do { activity = try Activity.request(attributes: attributes, content: content) trackedDeviceID = deviceID + lastPushedAt = Date() } catch { // Kann z.B. an fehlender Nutzerfreigabe liegen – dann bleibt es // einfach bei der Anzeige in der App, es gibt sonst nichts zu tun. @@ -55,8 +76,33 @@ final class LevelActivityManager { /// Wird bei jeder neuen Messung aufgerufen, unabhängig davon, ob gerade /// eine Aktivität läuft – kein Aufwand ohne aktive Anzeige. + /// + /// Innerhalb der Sperrfrist wird nicht einfach verworfen, sondern der + /// neueste Stand für ihr Ende vorgemerkt – ein älterer vorgemerkter + /// Aufruf verfällt dabei. So kommt bei schnellen Änderungen immer der + /// zuletzt gemessene Wert an, nur eben gesammelt statt einzeln. func update(deviceID: UUID, state: LevelState) { - guard trackedDeviceID == deviceID, let activity else { return } + guard trackedDeviceID == deviceID, activity != nil else { return } + pendingUpdate?.cancel() + pendingUpdate = nil + + let elapsed = lastPushedAt.map { Date().timeIntervalSince($0) } ?? .infinity + guard elapsed < Self.minimumUpdateInterval else { + push(state) + return + } + let delay = Self.minimumUpdateInterval - elapsed + pendingUpdate = Task { [weak self] in + try? await Task.sleep(for: .seconds(delay)) + guard let self, !Task.isCancelled, self.trackedDeviceID == deviceID else { return } + self.pendingUpdate = nil + self.push(state) + } + } + + private func push(_ state: LevelState) { + guard let activity else { return } + lastPushedAt = Date() let content = ActivityContent(state: Self.contentState(from: state), staleDate: nil) Task { await activity.update(content) } } @@ -100,6 +146,8 @@ final class LevelActivityManager { } private func endActivity() { + pendingUpdate?.cancel() + pendingUpdate = nil guard let activity else { return } trackedDeviceID = nil self.activity = nil