Add Live Activity for level sensor (surfaces on CarPlay automatically)

Replaces the ActivityKit boilerplate with a real lock screen/Dynamic
Island UI driven by live pitch/roll readings, toggled from the
leveling device's detail screen. Since iOS 17 shows any running Live
Activity on CarPlay's dashboard without a dedicated CarPlay app entitlement,
this covers the requested CarPlay use case without one.

Also fixes the widget extension never being embedded into the app
(missing Embed Foundation Extensions build phase and target
dependency), which meant no widget or Live Activity could ever have
rendered regardless of code changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
fototeddy
2026-09-04 22:39:01 +02:00
co-authored by Claude Sonnet 5
parent da64b1b6bd
commit bfe2b00a5c
7 changed files with 270 additions and 40 deletions
@@ -240,6 +240,10 @@ final class BluetoothManager: NSObject {
// MARK: - Sonstiges
private let store: DeviceStore
/// Von aussen gesetzt, sobald die App bereit ist. Optional, damit
/// `BluetoothManager` nichts über Live Activities wissen muss, wenn keine
/// läuft.
var activityManager: LevelActivityManager?
private let isDemo = DemoData.isEnabled
/// Wieviele Messpunkte je Gerät im Verlauf behalten werden.
private let historyLimit = 720
@@ -892,7 +896,10 @@ extension BluetoothManager: CBCentralManagerDelegate {
self?.publish { self?.linkStates[device.id] = state }
},
onLevelState: { [weak self] state in
self?.publish { self?.levelStates[device.id] = state }
self?.publish {
self?.levelStates[device.id] = state
self?.activityManager?.update(deviceID: device.id, state: state)
}
},
onDeviceOrientation: { [weak self] orientation in
// Im Gerät steht, wie der Sensor eingebaut ist für alle
+5
View File
@@ -5,14 +5,18 @@ struct CamperMonitorApp: App {
@State private var store: DeviceStore
@State private var bluetooth: BluetoothManager
@State private var watch: PhoneWatchLink
@State private var levelActivity: LevelActivityManager
@Environment(\.scenePhase) private var scenePhase
init() {
let store = DeviceStore()
let bluetooth = BluetoothManager(store: store)
let levelActivity = LevelActivityManager()
bluetooth.activityManager = levelActivity
_store = State(initialValue: store)
_bluetooth = State(initialValue: bluetooth)
_watch = State(initialValue: PhoneWatchLink(store: store, bluetooth: bluetooth))
_levelActivity = State(initialValue: levelActivity)
}
var body: some Scene {
@@ -21,6 +25,7 @@ struct CamperMonitorApp: App {
.environment(store)
.environment(bluetooth)
.environment(watch)
.environment(levelActivity)
.task { watch.activate() }
}
.onChange(of: scenePhase) { _, phase in
@@ -0,0 +1,59 @@
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 17 zeigt
/// CarPlay eine laufende Live Activity automatisch im Dashboard an; ein
/// eigenes CarPlay-App-Target braucht es dafür nicht.
@Observable
final class LevelActivityManager {
private(set) var trackedDeviceID: UUID?
@ObservationIgnored private var activity: Activity<LevelActivityAttributes>?
func isActive(for deviceID: UUID) -> Bool {
trackedDeviceID == deviceID && activity != nil
}
func start(deviceID: UUID, deviceName: String, state: LevelState) {
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
end()
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) }
}
func end() {
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()
)
}
}
+37
View File
@@ -107,6 +107,7 @@ struct LevelControls: View {
@Environment(BluetoothManager.self) private var bluetooth
@Environment(DeviceStore.self) private var store
@Environment(LevelActivityManager.self) private var levelActivity
@State private var showAssistant = false
@AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble
@@ -177,6 +178,29 @@ struct LevelControls: View {
}
}
Section {
Toggle(isOn: liveActivityBinding) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Live Activity")
Text("Sperrbildschirm, Dynamic Island und CarPlay")
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "widget.small")
}
}
// Ohne laufende Messwerte gäbe es nichts anzuzeigen und beim
// Umschalten hätte die Activity sofort einen veralteten Stand.
.disabled(!isLive || !state.hasReading)
} footer: {
Text(!isLive
? "Braucht laufende Messwerte. Der Neigungsmesser ist gerade nicht verbunden."
: "Zeigt die Neigung, solange sie läuft auch bei gesperrtem Bildschirm. "
+ "Ist das iPhone mit CarPlay verbunden, erscheint sie automatisch auch dort.")
}
// Einbaulage und Nullpunkt liegen auf einer eigenen Seite. Direkt unter
// der Libelle verstellte ein Fehlgriff beim Ablesen den Nullpunkt.
Section {
@@ -196,6 +220,19 @@ struct LevelControls: View {
private var isLive: Bool { bluetooth.linkStates[device.id] == .live }
private var liveActivityBinding: Binding<Bool> {
Binding(
get: { levelActivity.isActive(for: device.id) },
set: { isOn in
if isOn {
levelActivity.start(deviceID: device.id, deviceName: device.name, state: state)
} else {
levelActivity.end()
}
}
)
}
/// Was auf der Einrichtungsseite zu holen ist der Nullpunkt zuerst, denn
/// ohne ihn stimmt die Anzeige nicht.
private var setupSummary: String {