import ActivityKit import Foundation 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 26 zeigt /// CarPlay eine laufende Live Activity automatisch im Dashboard an; ein /// 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 `VanControlLiveActivity`). @Observable final class LevelActivityManager { private(set) var trackedDeviceID: UUID? @ObservationIgnored private var activity: Activity? /// 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? /// 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. var isActive: Bool { trackedDeviceID != nil } func isActive(for deviceID: UUID) -> Bool { trackedDeviceID == deviceID && activity != nil } func start(deviceID: UUID, deviceName: String, state: LevelState) { pendingEnd?.cancel() pendingEnd = nil deviceToResume = nil guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } endActivity() let attributes = LevelActivityAttributes(deviceName: deviceName) let content = ActivityContent(state: Self.contentState(from: state), staleDate: nil) do { activity = try Activity.request(attributes: attributes, content: content) trackedDeviceID = deviceID } catch { // Kann z.B. an fehlender Nutzerfreigabe liegen – dann bleibt es // einfach bei der Anzeige in der App, es gibt sonst nichts zu tun. } } /// Wird bei jeder neuen Messung aufgerufen, unabhängig davon, ob gerade /// eine Aktivität läuft – kein Aufwand ohne aktive Anzeige. func update(deviceID: UUID, state: LevelState) { guard trackedDeviceID == deviceID, let activity else { return } let content = ActivityContent(state: Self.contentState(from: state), staleDate: nil) 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 Task { await activity.end(nil, dismissalPolicy: .immediate) } } private static func contentState(from state: LevelState) -> LevelActivityAttributes.ContentState { LevelActivityAttributes.ContentState( pitch: state.pitch, roll: state.roll, isLevel: state.isLevel, instruction: state.instruction, isCalibrated: state.isCalibrated, updatedAt: Date() ) } }