Files
Camper-Monitor/CamperMonitor/LiveActivity/LevelActivityManager.swift
T
fototeddyandClaude Sonnet 5 e9b9c5bcd5 Solar-Integration und dev_watch-Geräte zusammenführen
solar-integration (aus dem separaten VanAligneiOS-Repo) und dev_watch
haben unabhängige Git-Historien, decken aber überlappende und sich
ergänzende Funktionen ab. Übernommen aus solar-integration: Votronic-
Solar-ESP-Anbindung samt Geräterolle, die Live-Activity/Widget-Extension
fürs Sperrbildschirm/Dynamic-Island/CarPlay, das Querformat-Layout für
Libelle/Fahrzeug-Ansicht und Ausrichtungs-Assistent, sowie die
mehreren Fahrzeuggrafik-Stile (Vanster/California). Beibehalten aus
dev_watch: alle zusätzlichen Geräteprotokolle (Daly-/JBD-BMS, Alpicool-
Kühlbox, WattCycle, Victron), die dort zwischenzeitlich entstanden.

Die Xcode-Projektdatei wurde von Hand um die neue Widget-Extension samt
SharedActivity-Gruppe erweitert (Datei-synchronisierte Gruppen, kein
App-Group-Entitlement nötig). Build für App, Watch und Widget-Extension
geprüft (Debug und Release).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 21:00:14 +02:00

120 lines
5.1 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 `VanAligneiOSWidgetLiveActivity`).
@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()
)
}
}