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:
co-authored by
Claude Sonnet 5
parent
da64b1b6bd
commit
bfe2b00a5c
@@ -240,6 +240,10 @@ final class BluetoothManager: NSObject {
|
|||||||
// MARK: - Sonstiges
|
// MARK: - Sonstiges
|
||||||
|
|
||||||
private let store: DeviceStore
|
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
|
private let isDemo = DemoData.isEnabled
|
||||||
/// Wieviele Messpunkte je Gerät im Verlauf behalten werden.
|
/// Wieviele Messpunkte je Gerät im Verlauf behalten werden.
|
||||||
private let historyLimit = 720
|
private let historyLimit = 720
|
||||||
@@ -892,7 +896,10 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
|||||||
self?.publish { self?.linkStates[device.id] = state }
|
self?.publish { self?.linkStates[device.id] = state }
|
||||||
},
|
},
|
||||||
onLevelState: { [weak self] state in
|
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
|
onDeviceOrientation: { [weak self] orientation in
|
||||||
// Im Gerät steht, wie der Sensor eingebaut ist – für alle
|
// Im Gerät steht, wie der Sensor eingebaut ist – für alle
|
||||||
|
|||||||
@@ -5,14 +5,18 @@ struct CamperMonitorApp: App {
|
|||||||
@State private var store: DeviceStore
|
@State private var store: DeviceStore
|
||||||
@State private var bluetooth: BluetoothManager
|
@State private var bluetooth: BluetoothManager
|
||||||
@State private var watch: PhoneWatchLink
|
@State private var watch: PhoneWatchLink
|
||||||
|
@State private var levelActivity: LevelActivityManager
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let store = DeviceStore()
|
let store = DeviceStore()
|
||||||
let bluetooth = BluetoothManager(store: store)
|
let bluetooth = BluetoothManager(store: store)
|
||||||
|
let levelActivity = LevelActivityManager()
|
||||||
|
bluetooth.activityManager = levelActivity
|
||||||
_store = State(initialValue: store)
|
_store = State(initialValue: store)
|
||||||
_bluetooth = State(initialValue: bluetooth)
|
_bluetooth = State(initialValue: bluetooth)
|
||||||
_watch = State(initialValue: PhoneWatchLink(store: store, bluetooth: bluetooth))
|
_watch = State(initialValue: PhoneWatchLink(store: store, bluetooth: bluetooth))
|
||||||
|
_levelActivity = State(initialValue: levelActivity)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
@@ -21,6 +25,7 @@ struct CamperMonitorApp: App {
|
|||||||
.environment(store)
|
.environment(store)
|
||||||
.environment(bluetooth)
|
.environment(bluetooth)
|
||||||
.environment(watch)
|
.environment(watch)
|
||||||
|
.environment(levelActivity)
|
||||||
.task { watch.activate() }
|
.task { watch.activate() }
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { _, phase in
|
.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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,6 +107,7 @@ struct LevelControls: View {
|
|||||||
|
|
||||||
@Environment(BluetoothManager.self) private var bluetooth
|
@Environment(BluetoothManager.self) private var bluetooth
|
||||||
@Environment(DeviceStore.self) private var store
|
@Environment(DeviceStore.self) private var store
|
||||||
|
@Environment(LevelActivityManager.self) private var levelActivity
|
||||||
@State private var showAssistant = false
|
@State private var showAssistant = false
|
||||||
@AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble
|
@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
|
// Einbaulage und Nullpunkt liegen auf einer eigenen Seite. Direkt unter
|
||||||
// der Libelle verstellte ein Fehlgriff beim Ablesen den Nullpunkt.
|
// der Libelle verstellte ein Fehlgriff beim Ablesen den Nullpunkt.
|
||||||
Section {
|
Section {
|
||||||
@@ -196,6 +220,19 @@ struct LevelControls: View {
|
|||||||
|
|
||||||
private var isLive: Bool { bluetooth.linkStates[device.id] == .live }
|
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
|
/// Was auf der Einrichtungsseite zu holen ist – der Nullpunkt zuerst, denn
|
||||||
/// ohne ihn stimmt die Anzeige nicht.
|
/// ohne ihn stimmt die Anzeige nicht.
|
||||||
private var setupSummary: String {
|
private var setupSummary: String {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -22,6 +22,9 @@
|
|||||||
9DB55BAF3048B29B00CFD174 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9DB55BAE3048B29B00CFD174 /* WidgetKit.framework */; };
|
9DB55BAF3048B29B00CFD174 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9DB55BAE3048B29B00CFD174 /* WidgetKit.framework */; };
|
||||||
9DB55BB13048B29B00CFD174 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9DB55BB03048B29B00CFD174 /* SwiftUI.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, ); }; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -32,9 +35,27 @@
|
|||||||
remoteGlobalIDString = AA0000000000000000000022;
|
remoteGlobalIDString = AA0000000000000000000022;
|
||||||
remoteInfo = CamperMonitorWatch;
|
remoteInfo = CamperMonitorWatch;
|
||||||
};
|
};
|
||||||
|
AA0000000000000000000045 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = AA0000000000000000000009 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 9DB55BAB3048B29B00CFD174;
|
||||||
|
remoteInfo = VanAligneiOSWidgetExtension;
|
||||||
|
};
|
||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase 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 */ = {
|
AA0000000000000000000026 /* Embed Watch Content */ = {
|
||||||
isa = PBXCopyFilesBuildPhase;
|
isa = PBXCopyFilesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -59,6 +80,7 @@
|
|||||||
AA0000000000000000000001 /* VanAligne.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VanAligne.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
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; };
|
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; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
@@ -100,6 +122,11 @@
|
|||||||
path = CamperMonitorComplication;
|
path = CamperMonitorComplication;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
AA0000000000000000000041 /* SharedActivity */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = SharedActivity;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -109,6 +136,7 @@
|
|||||||
files = (
|
files = (
|
||||||
9DB55BB13048B29B00CFD174 /* SwiftUI.framework in Frameworks */,
|
9DB55BB13048B29B00CFD174 /* SwiftUI.framework in Frameworks */,
|
||||||
9DB55BAF3048B29B00CFD174 /* WidgetKit.framework in Frameworks */,
|
9DB55BAF3048B29B00CFD174 /* WidgetKit.framework in Frameworks */,
|
||||||
|
AA0000000000000000000044 /* ActivityKit.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -116,6 +144,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
AA0000000000000000000043 /* ActivityKit.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -141,6 +170,7 @@
|
|||||||
children = (
|
children = (
|
||||||
9DB55BAE3048B29B00CFD174 /* WidgetKit.framework */,
|
9DB55BAE3048B29B00CFD174 /* WidgetKit.framework */,
|
||||||
9DB55BB03048B29B00CFD174 /* SwiftUI.framework */,
|
9DB55BB03048B29B00CFD174 /* SwiftUI.framework */,
|
||||||
|
AA0000000000000000000042 /* ActivityKit.framework */,
|
||||||
);
|
);
|
||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -152,6 +182,7 @@
|
|||||||
AA0000000000000000000017 /* CamperMonitorWatch */,
|
AA0000000000000000000017 /* CamperMonitorWatch */,
|
||||||
AA0000000000000000000033 /* CamperMonitorComplication */,
|
AA0000000000000000000033 /* CamperMonitorComplication */,
|
||||||
AA0000000000000000000018 /* Shared */,
|
AA0000000000000000000018 /* Shared */,
|
||||||
|
AA0000000000000000000041 /* SharedActivity */,
|
||||||
AA0000000000000000000031 /* Config */,
|
AA0000000000000000000031 /* Config */,
|
||||||
9DB55BB23048B29B00CFD174 /* VanAligneiOSWidget */,
|
9DB55BB23048B29B00CFD174 /* VanAligneiOSWidget */,
|
||||||
9DB55BAD3048B29B00CFD174 /* Frameworks */,
|
9DB55BAD3048B29B00CFD174 /* Frameworks */,
|
||||||
@@ -198,6 +229,7 @@
|
|||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
9DB55BB23048B29B00CFD174 /* VanAligneiOSWidget */,
|
9DB55BB23048B29B00CFD174 /* VanAligneiOSWidget */,
|
||||||
|
AA0000000000000000000041 /* SharedActivity */,
|
||||||
);
|
);
|
||||||
name = VanAligneiOSWidgetExtension;
|
name = VanAligneiOSWidgetExtension;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
@@ -214,15 +246,18 @@
|
|||||||
AA0000000000000000000003 /* Frameworks */,
|
AA0000000000000000000003 /* Frameworks */,
|
||||||
AA0000000000000000000008 /* Resources */,
|
AA0000000000000000000008 /* Resources */,
|
||||||
AA0000000000000000000026 /* Embed Watch Content */,
|
AA0000000000000000000026 /* Embed Watch Content */,
|
||||||
|
AA0000000000000000000048 /* Embed Foundation Extensions */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
AA0000000000000000000029 /* PBXTargetDependency */,
|
AA0000000000000000000029 /* PBXTargetDependency */,
|
||||||
|
AA0000000000000000000046 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
AA0000000000000000000002 /* CamperMonitor */,
|
AA0000000000000000000002 /* CamperMonitor */,
|
||||||
AA0000000000000000000018 /* Shared */,
|
AA0000000000000000000018 /* Shared */,
|
||||||
|
AA0000000000000000000041 /* SharedActivity */,
|
||||||
);
|
);
|
||||||
name = VanAligne;
|
name = VanAligne;
|
||||||
productName = CamperMonitor;
|
productName = CamperMonitor;
|
||||||
@@ -398,6 +433,11 @@
|
|||||||
target = AA0000000000000000000022 /* VanAligneWatch */;
|
target = AA0000000000000000000022 /* VanAligneWatch */;
|
||||||
targetProxy = AA0000000000000000000028 /* PBXContainerItemProxy */;
|
targetProxy = AA0000000000000000000028 /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
|
AA0000000000000000000046 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 9DB55BAB3048B29B00CFD174 /* VanAligneiOSWidgetExtension */;
|
||||||
|
targetProxy = AA0000000000000000000045 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
@@ -603,6 +643,7 @@
|
|||||||
INFOPLIST_KEY_CFBundleDisplayName = "VanAligne Pro";
|
INFOPLIST_KEY_CFBundleDisplayName = "VanAligne Pro";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth.";
|
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_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
@@ -635,6 +676,7 @@
|
|||||||
INFOPLIST_KEY_CFBundleDisplayName = "VanAligne Pro";
|
INFOPLIST_KEY_CFBundleDisplayName = "VanAligne Pro";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth.";
|
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_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
|||||||
@@ -9,72 +9,129 @@ import ActivityKit
|
|||||||
import WidgetKit
|
import WidgetKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct VanAligneiOSWidgetAttributes: ActivityAttributes {
|
/// Formatiert Neigungswerte wie in der App – °, eine Nachkommastelle, "–"
|
||||||
public struct ContentState: Codable, Hashable {
|
/// wenn kein Messwert da ist. Eigene Kopie, da die Extension nicht von
|
||||||
// Dynamic stateful properties about your activity go here!
|
/// `Shared/Models/Metric.swift` abhängt.
|
||||||
var emoji: String
|
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!
|
private struct LevelIcon: View {
|
||||||
var name: String
|
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 {
|
struct VanAligneiOSWidgetLiveActivity: Widget {
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
ActivityConfiguration(for: VanAligneiOSWidgetAttributes.self) { context in
|
ActivityConfiguration(for: LevelActivityAttributes.self) { context in
|
||||||
// Lock screen/banner UI goes here
|
LockScreenLevelView(attributes: context.attributes, state: context.state)
|
||||||
VStack {
|
.activityBackgroundTint(Color(white: 0.08))
|
||||||
Text("Hello \(context.state.emoji)")
|
.activitySystemActionForegroundColor(.white)
|
||||||
}
|
|
||||||
.activityBackgroundTint(Color.cyan)
|
|
||||||
.activitySystemActionForegroundColor(Color.black)
|
|
||||||
|
|
||||||
} dynamicIsland: { context in
|
} dynamicIsland: { context in
|
||||||
DynamicIsland {
|
DynamicIsland {
|
||||||
// Expanded UI goes here. Compose the expanded UI through
|
|
||||||
// various regions, like leading/trailing/center/bottom
|
|
||||||
DynamicIslandExpandedRegion(.leading) {
|
DynamicIslandExpandedRegion(.leading) {
|
||||||
Text("Leading")
|
LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil)
|
||||||
|
.font(.title2)
|
||||||
}
|
}
|
||||||
DynamicIslandExpandedRegion(.trailing) {
|
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) {
|
DynamicIslandExpandedRegion(.bottom) {
|
||||||
Text("Bottom \(context.state.emoji)")
|
Text(context.state.instruction ?? context.attributes.deviceName)
|
||||||
// more content
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(context.state.isLevel ? .green : .primary)
|
||||||
}
|
}
|
||||||
} compactLeading: {
|
} compactLeading: {
|
||||||
Text("L")
|
LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil)
|
||||||
} compactTrailing: {
|
} compactTrailing: {
|
||||||
Text("T \(context.state.emoji)")
|
Text(degreesText(context.state.pitch)).monospacedDigit()
|
||||||
} minimal: {
|
} minimal: {
|
||||||
Text(context.state.emoji)
|
LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil)
|
||||||
}
|
}
|
||||||
.widgetURL(URL(string: "http://www.apple.com"))
|
.keylineTint(context.state.isLevel ? .green : .orange)
|
||||||
.keylineTint(Color.red)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension VanAligneiOSWidgetAttributes {
|
/// Sperrbildschirm- und Banner-Ansicht. Dieselbe Ansicht landet automatisch
|
||||||
fileprivate static var preview: VanAligneiOSWidgetAttributes {
|
/// auch im CarPlay-Dashboard, sobald das iPhone verbunden ist – ein eigenes
|
||||||
VanAligneiOSWidgetAttributes(name: "World")
|
/// 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 {
|
extension LevelActivityAttributes {
|
||||||
fileprivate static var smiley: VanAligneiOSWidgetAttributes.ContentState {
|
fileprivate static var preview: LevelActivityAttributes {
|
||||||
VanAligneiOSWidgetAttributes.ContentState(emoji: "😀")
|
LevelActivityAttributes(deviceName: "Nivellierung")
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate static var starEyes: VanAligneiOSWidgetAttributes.ContentState {
|
|
||||||
VanAligneiOSWidgetAttributes.ContentState(emoji: "🤩")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#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()
|
VanAligneiOSWidgetLiveActivity()
|
||||||
} contentStates: {
|
} contentStates: {
|
||||||
VanAligneiOSWidgetAttributes.ContentState.smiley
|
LevelActivityAttributes.ContentState.level
|
||||||
VanAligneiOSWidgetAttributes.ContentState.starEyes
|
LevelActivityAttributes.ContentState.tilted
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user