forked from fritob/Camper-Monitor
Nach dem Entfernen der Beispiel-Widget/-Control-Dateien enthielt
VanControlWidget(Extension) nur noch die Live Activity – der Name war
damit irreführend. Jetzt VanControlLiveActivity(Extension) durchgängig:
Ordner, Target, Produkt, Bundle-ID-Suffix, Dateien
(VanControlLiveActivity.swift, VanControlLiveActivityBundle.swift) und
Anzeigename ("VanControl Live Activity").
Build (App Debug+Release, Watch) und Protokoll-Tests grün.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
120 lines
5.1 KiB
Swift
120 lines
5.1 KiB
Swift
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<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.
|
||
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()
|
||
)
|
||
}
|
||
}
|