diff --git a/CamperMonitor/Bluetooth/BluetoothManager.swift b/CamperMonitor/Bluetooth/BluetoothManager.swift index 272090f..d9a8cef 100644 --- a/CamperMonitor/Bluetooth/BluetoothManager.swift +++ b/CamperMonitor/Bluetooth/BluetoothManager.swift @@ -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 diff --git a/CamperMonitor/CamperMonitorApp.swift b/CamperMonitor/CamperMonitorApp.swift index bf4eacc..2aa5d11 100644 --- a/CamperMonitor/CamperMonitorApp.swift +++ b/CamperMonitor/CamperMonitorApp.swift @@ -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 diff --git a/CamperMonitor/LiveActivity/LevelActivityManager.swift b/CamperMonitor/LiveActivity/LevelActivityManager.swift new file mode 100644 index 0000000..fb0018f --- /dev/null +++ b/CamperMonitor/LiveActivity/LevelActivityManager.swift @@ -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? + + 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() + ) + } +} diff --git a/CamperMonitor/Views/LevelView.swift b/CamperMonitor/Views/LevelView.swift index e427162..72c9170 100644 --- a/CamperMonitor/Views/LevelView.swift +++ b/CamperMonitor/Views/LevelView.swift @@ -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 { + 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 { diff --git a/SharedActivity/LevelActivityAttributes.swift b/SharedActivity/LevelActivityAttributes.swift new file mode 100644 index 0000000..21c2ce5 --- /dev/null +++ b/SharedActivity/LevelActivityAttributes.swift @@ -0,0 +1,23 @@ +import ActivityKit +import Foundation + +/// Inhalt der Neigungsmesser-Live-Activity. +/// +/// Die App und die Widget-Extension kompilieren diese Datei je für sich in +/// ihr eigenes Modul – sie darf deshalb nicht von `Shared/` abhängen, sonst +/// müsste die Extension auch Bluetooth- und WatchConnectivity-Code mitbauen. +/// Werte wie `isLevel` und `instruction` kommen darum schon fertig berechnet +/// aus `LevelState` an, die Extension zeigt nur an. +struct LevelActivityAttributes: ActivityAttributes { + struct ContentState: Codable, Hashable { + var pitch: Double? + var roll: Double? + var isLevel: Bool + var instruction: String? + var isCalibrated: Bool + var updatedAt: Date + } + + /// Name des Neigungsmessers, wie in der App vergeben. + var deviceName: String +} diff --git a/VanAligneiOS.xcodeproj/project.pbxproj b/VanAligneiOS.xcodeproj/project.pbxproj index e9a1945..dc8db19 100644 --- a/VanAligneiOS.xcodeproj/project.pbxproj +++ b/VanAligneiOS.xcodeproj/project.pbxproj @@ -22,6 +22,9 @@ 9DB55BAF3048B29B00CFD174 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9DB55BAE3048B29B00CFD174 /* WidgetKit.framework */; }; 9DB55BB13048B29B00CFD174 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9DB55BB03048B29B00CFD174 /* SwiftUI.framework */; }; AA0000000000000000000027 /* VanAligneWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000016 /* VanAligneWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + AA0000000000000000000047 /* VanAligneiOSWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9DB55BAC3048B29B00CFD174 /* VanAligneiOSWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + AA0000000000000000000043 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000042 /* ActivityKit.framework */; }; + AA0000000000000000000044 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000042 /* ActivityKit.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -32,9 +35,27 @@ remoteGlobalIDString = AA0000000000000000000022; remoteInfo = CamperMonitorWatch; }; + AA0000000000000000000045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = AA0000000000000000000009 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 9DB55BAB3048B29B00CFD174; + remoteInfo = VanAligneiOSWidgetExtension; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + AA0000000000000000000048 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + AA0000000000000000000047 /* VanAligneiOSWidgetExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; AA0000000000000000000026 /* Embed Watch Content */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -59,6 +80,7 @@ AA0000000000000000000001 /* VanAligne.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VanAligne.app; sourceTree = BUILT_PRODUCTS_DIR; }; AA0000000000000000000016 /* VanAligneWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VanAligneWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; AA0000000000000000000032 /* VanAligneComplication.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = VanAligneComplication.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + AA0000000000000000000042 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -100,6 +122,11 @@ path = CamperMonitorComplication; sourceTree = ""; }; + AA0000000000000000000041 /* SharedActivity */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = SharedActivity; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -109,6 +136,7 @@ files = ( 9DB55BB13048B29B00CFD174 /* SwiftUI.framework in Frameworks */, 9DB55BAF3048B29B00CFD174 /* WidgetKit.framework in Frameworks */, + AA0000000000000000000044 /* ActivityKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -116,6 +144,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + AA0000000000000000000043 /* ActivityKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -141,6 +170,7 @@ children = ( 9DB55BAE3048B29B00CFD174 /* WidgetKit.framework */, 9DB55BB03048B29B00CFD174 /* SwiftUI.framework */, + AA0000000000000000000042 /* ActivityKit.framework */, ); name = Frameworks; sourceTree = ""; @@ -152,6 +182,7 @@ AA0000000000000000000017 /* CamperMonitorWatch */, AA0000000000000000000033 /* CamperMonitorComplication */, AA0000000000000000000018 /* Shared */, + AA0000000000000000000041 /* SharedActivity */, AA0000000000000000000031 /* Config */, 9DB55BB23048B29B00CFD174 /* VanAligneiOSWidget */, 9DB55BAD3048B29B00CFD174 /* Frameworks */, @@ -198,6 +229,7 @@ ); fileSystemSynchronizedGroups = ( 9DB55BB23048B29B00CFD174 /* VanAligneiOSWidget */, + AA0000000000000000000041 /* SharedActivity */, ); name = VanAligneiOSWidgetExtension; packageProductDependencies = ( @@ -214,15 +246,18 @@ AA0000000000000000000003 /* Frameworks */, AA0000000000000000000008 /* Resources */, AA0000000000000000000026 /* Embed Watch Content */, + AA0000000000000000000048 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( AA0000000000000000000029 /* PBXTargetDependency */, + AA0000000000000000000046 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( AA0000000000000000000002 /* CamperMonitor */, AA0000000000000000000018 /* Shared */, + AA0000000000000000000041 /* SharedActivity */, ); name = VanAligne; productName = CamperMonitor; @@ -398,6 +433,11 @@ target = AA0000000000000000000022 /* VanAligneWatch */; targetProxy = AA0000000000000000000028 /* PBXContainerItemProxy */; }; + AA0000000000000000000046 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 9DB55BAB3048B29B00CFD174 /* VanAligneiOSWidgetExtension */; + targetProxy = AA0000000000000000000045 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -603,6 +643,7 @@ INFOPLIST_KEY_CFBundleDisplayName = "VanAligne Pro"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth."; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -635,6 +676,7 @@ INFOPLIST_KEY_CFBundleDisplayName = "VanAligne Pro"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth."; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; diff --git a/VanAligneiOSWidget/VanAligneiOSWidgetLiveActivity.swift b/VanAligneiOSWidget/VanAligneiOSWidgetLiveActivity.swift index 4434fb8..8246175 100644 --- a/VanAligneiOSWidget/VanAligneiOSWidgetLiveActivity.swift +++ b/VanAligneiOSWidget/VanAligneiOSWidgetLiveActivity.swift @@ -9,72 +9,129 @@ import ActivityKit import WidgetKit import SwiftUI -struct VanAligneiOSWidgetAttributes: ActivityAttributes { - public struct ContentState: Codable, Hashable { - // Dynamic stateful properties about your activity go here! - var emoji: String - } +/// Formatiert Neigungswerte wie in der App – °, eine Nachkommastelle, "–" +/// wenn kein Messwert da ist. Eigene Kopie, da die Extension nicht von +/// `Shared/Models/Metric.swift` abhängt. +private func degreesText(_ value: Double?) -> String { + guard let value else { return "–" } + return String(format: "%.1f°", value) +} - // Fixed non-changing properties about your activity go here! - var name: String +private struct LevelIcon: View { + let isLevel: Bool + let hasReading: Bool + + var body: some View { + Image(systemName: hasReading ? (isLevel ? "checkmark.circle.fill" : "level") : "level") + .foregroundStyle(hasReading ? (isLevel ? .green : .orange) : .secondary) + } } struct VanAligneiOSWidgetLiveActivity: Widget { var body: some WidgetConfiguration { - ActivityConfiguration(for: VanAligneiOSWidgetAttributes.self) { context in - // Lock screen/banner UI goes here - VStack { - Text("Hello \(context.state.emoji)") - } - .activityBackgroundTint(Color.cyan) - .activitySystemActionForegroundColor(Color.black) + ActivityConfiguration(for: LevelActivityAttributes.self) { context in + LockScreenLevelView(attributes: context.attributes, state: context.state) + .activityBackgroundTint(Color(white: 0.08)) + .activitySystemActionForegroundColor(.white) } dynamicIsland: { context in DynamicIsland { - // Expanded UI goes here. Compose the expanded UI through - // various regions, like leading/trailing/center/bottom DynamicIslandExpandedRegion(.leading) { - Text("Leading") + LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil) + .font(.title2) } DynamicIslandExpandedRegion(.trailing) { - Text("Trailing") + VStack(alignment: .trailing, spacing: 2) { + Text(degreesText(context.state.pitch)).monospacedDigit() + Text(degreesText(context.state.roll)).monospacedDigit() + } + .font(.headline) } DynamicIslandExpandedRegion(.bottom) { - Text("Bottom \(context.state.emoji)") - // more content + Text(context.state.instruction ?? context.attributes.deviceName) + .font(.subheadline) + .foregroundStyle(context.state.isLevel ? .green : .primary) } } compactLeading: { - Text("L") + LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil) } compactTrailing: { - Text("T \(context.state.emoji)") + Text(degreesText(context.state.pitch)).monospacedDigit() } minimal: { - Text(context.state.emoji) + LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil) } - .widgetURL(URL(string: "http://www.apple.com")) - .keylineTint(Color.red) + .keylineTint(context.state.isLevel ? .green : .orange) } } } -extension VanAligneiOSWidgetAttributes { - fileprivate static var preview: VanAligneiOSWidgetAttributes { - VanAligneiOSWidgetAttributes(name: "World") +/// Sperrbildschirm- und Banner-Ansicht. Dieselbe Ansicht landet automatisch +/// auch im CarPlay-Dashboard, sobald das iPhone verbunden ist – ein eigenes +/// CarPlay-App-Target ist dafür nicht nötig. +private struct LockScreenLevelView: View { + let attributes: LevelActivityAttributes + let state: LevelActivityAttributes.ContentState + + var body: some View { + HStack(spacing: 16) { + LevelIcon(isLevel: state.isLevel, hasReading: state.pitch != nil) + .font(.system(size: 32)) + .frame(width: 40) + + VStack(alignment: .leading, spacing: 4) { + Text(attributes.deviceName) + .font(.caption) + .foregroundStyle(.secondary) + Text(state.instruction ?? "Keine Messwerte") + .font(.headline) + .foregroundStyle(state.isLevel ? .green : .white) + .lineLimit(2) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + reading("Längs", state.pitch) + reading("Quer", state.roll) + } + } + .padding(16) + .foregroundStyle(.white) + } + + private func reading(_ title: String, _ value: Double?) -> some View { + HStack(spacing: 6) { + Text(title) + .font(.caption2) + .foregroundStyle(.secondary) + Text(degreesText(value)) + .font(.subheadline.monospacedDigit()) + } } } -extension VanAligneiOSWidgetAttributes.ContentState { - fileprivate static var smiley: VanAligneiOSWidgetAttributes.ContentState { - VanAligneiOSWidgetAttributes.ContentState(emoji: "😀") - } - - fileprivate static var starEyes: VanAligneiOSWidgetAttributes.ContentState { - VanAligneiOSWidgetAttributes.ContentState(emoji: "🤩") - } +extension LevelActivityAttributes { + fileprivate static var preview: LevelActivityAttributes { + LevelActivityAttributes(deviceName: "Nivellierung") + } } -#Preview("Notification", as: .content, using: VanAligneiOSWidgetAttributes.preview) { +extension LevelActivityAttributes.ContentState { + fileprivate static var level: LevelActivityAttributes.ContentState { + LevelActivityAttributes.ContentState(pitch: 0.1, roll: -0.2, isLevel: true, + instruction: "Steht eben", isCalibrated: true, + updatedAt: .now) + } + + fileprivate static var tilted: LevelActivityAttributes.ContentState { + LevelActivityAttributes.ContentState(pitch: 2.4, roll: -1.1, isLevel: false, + instruction: "Heck steht höher, links steht höher", + isCalibrated: true, updatedAt: .now) + } +} + +#Preview("Notification", as: .content, using: LevelActivityAttributes.preview) { VanAligneiOSWidgetLiveActivity() } contentStates: { - VanAligneiOSWidgetAttributes.ContentState.smiley - VanAligneiOSWidgetAttributes.ContentState.starEyes + LevelActivityAttributes.ContentState.level + LevelActivityAttributes.ContentState.tilted }