Compare commits

...
2 Commits
Author SHA1 Message Date
fototeddyandClaude Sonnet 5 9a3b5cb472 Fix device signing/companion ID, duplicate-device crash, background updates, and add level bubble to Live Activity
- Assign the shared development team to the widget extension and watch
  complication targets, and correct the watch app's companion bundle
  identifier so on-device installs succeed after the bundle ID rename
  to de.s0.fototeddy.VanAligneiOS.
- Guard against two ConfiguredDevices sharing a peripheralID (e.g. a
  double-tapped "Sichern"), which crashed BluetoothManager's
  Dictionary(uniqueKeysWithValues:) on launch.
- Keep Bluetooth running in the background while a Live Activity is
  active, so lock screen/CarPlay keep receiving fresh readings instead
  of freezing at the last value before backgrounding.
- Render the actual bubble-level graphic (not just a static icon) in
  the Live Activity's lock screen view and expanded Dynamic Island.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 23:50:08 +02:00
fototeddyandClaude Sonnet 5 bfe2b00a5c 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>
2026-09-04 22:39:01 +02:00
8 changed files with 366 additions and 59 deletions
+13 -2
View File
@@ -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
@@ -513,7 +517,11 @@ final class BluetoothManager: NSObject {
// MARK: - Ablauf auf der Funk-Queue
private func applyConfiguration(_ devices: [ManagedDevice]) {
managed = Dictionary(uniqueKeysWithValues: devices.map { ($0.peripheralID, $0) })
// Mit `uniqueKeysWithValues` stürzt das bei doppelt eingerichteten
// Geräten (gleiches Peripheral, aus Versehen zweimal hinzugefügt) ab.
// Der letzte Eintrag gewinnt besser eine überschriebene Dublette als
// ein Absturz der ganzen Funk-Schicht.
managed = Dictionary(devices.map { ($0.peripheralID, $0) }, uniquingKeysWith: { _, latest in latest })
// Verbindungen zu Geräten lösen, die nicht mehr dazugehören.
let wanted = Set(devices.filter { shouldStayConnected($0) }.map(\.peripheralID))
@@ -892,7 +900,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
+10 -1
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
@@ -38,7 +43,11 @@ struct CamperMonitorApp: App {
case .active:
bluetooth.start()
case .background:
if !watch.wantsLiveUpdates { bluetooth.stop() }
// Läuft gerade eine Live Activity, muss der Neigungsmesser
// auch mit dunklem Bildschirm weiter Werte liefern sonst
// friert die Anzeige auf Sperrbildschirm und CarPlay beim
// ersten Wegdrücken der App ein.
if !watch.wantsLiveUpdates && !levelActivity.isActive { bluetooth.stop() }
default:
break
}
@@ -0,0 +1,64 @@
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>?
/// 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) {
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()
)
}
}
+7
View File
@@ -94,7 +94,14 @@ final class DeviceStore {
// MARK: - Geräte
/// Legt ein Gerät im gerade gewählten Profil an.
///
/// Dasselbe Peripheral doppelt im selben Profil anzulegen, würde später
/// beim Aufbau der Funk-Konfiguration abstürzen (die dortige Zuordnung
/// nach Peripheral-ID verlangt Eindeutigkeit) etwa bei einem
/// Doppel-Tipp auf Sichern. Ein zweiter Eintrag ersetzt darum den
/// ersten, statt sich danebenzustellen.
func add(_ device: ConfiguredDevice, victronKey: String? = nil) {
devices.removeAll { $0.profileID == device.profileID && $0.peripheralID == device.peripheralID }
devices.append(device)
if let victronKey {
setVictronKey(victronKey, for: device.id)
+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 {
@@ -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
}
+60 -18
View File
@@ -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, ); }; };
AA0000000000000000000043 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000042 /* ActivityKit.framework */; };
AA0000000000000000000044 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000042 /* ActivityKit.framework */; };
AA0000000000000000000047 /* VanAligneiOSWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9DB55BAC3048B29B00CFD174 /* VanAligneiOSWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -32,6 +35,13 @@
remoteGlobalIDString = AA0000000000000000000022;
remoteInfo = CamperMonitorWatch;
};
AA0000000000000000000045 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AA0000000000000000000009 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 9DB55BAB3048B29B00CFD174;
remoteInfo = VanAligneiOSWidgetExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -46,6 +56,17 @@
name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0;
};
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;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
@@ -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 = "<group>";
};
AA0000000000000000000041 /* SharedActivity */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = SharedActivity;
sourceTree = "<group>";
};
/* 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 = "<group>";
@@ -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 */
@@ -435,7 +475,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
DEVELOPMENT_TEAM = V5C6Q86XJR;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
@@ -454,7 +494,7 @@
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = fototeddy.VanAligneiOS.VanAligneiOSWidget;
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanAligneiOS.VanAligneiOSWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
@@ -501,7 +541,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
DEVELOPMENT_TEAM = V5C6Q86XJR;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
@@ -520,7 +560,7 @@
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = fototeddy.VanAligneiOS.VanAligneiOSWidget;
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanAligneiOS.VanAligneiOSWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
@@ -596,13 +636,14 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = P8J6283TT5;
DEVELOPMENT_TEAM = V5C6Q86XJR;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/CamperMonitor-Info.plist";
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";
@@ -612,7 +653,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = fototeddy.VanAligneiOS;
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanAligneiOS;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
@@ -628,13 +669,14 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = P8J6283TT5;
DEVELOPMENT_TEAM = V5C6Q86XJR;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/CamperMonitor-Info.plist";
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";
@@ -644,7 +686,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = fototeddy.VanAligneiOS;
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanAligneiOS;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
@@ -659,18 +701,18 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = P8J6283TT5;
DEVELOPMENT_TEAM = V5C6Q86XJR;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "VanAligne Pro";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone.";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = fototeddy.VanAligneiOS;
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.s0.fototeddy.VanAligneiOS;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = fototeddy.VanAligneiOS.watchkitapp;
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanAligneiOS.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
@@ -691,18 +733,18 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = P8J6283TT5;
DEVELOPMENT_TEAM = V5C6Q86XJR;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "VanAligne Pro";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone.";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = fototeddy.VanAligneiOS;
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.s0.fototeddy.VanAligneiOS;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = fototeddy.VanAligneiOS.watchkitapp;
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanAligneiOS.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
@@ -722,7 +764,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
DEVELOPMENT_TEAM = V5C6Q86XJR;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/CamperMonitorComplication-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Nivellierung;
@@ -732,7 +774,7 @@
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = fototeddy.VanAligneiOS.watchkitapp.levelwidget;
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanAligneiOS.watchkitapp.levelwidget;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = watchos;
@@ -753,7 +795,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
DEVELOPMENT_TEAM = V5C6Q86XJR;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/CamperMonitorComplication-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Nivellierung;
@@ -763,7 +805,7 @@
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = fototeddy.VanAligneiOS.watchkitapp.levelwidget;
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanAligneiOS.watchkitapp.levelwidget;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = watchos;
@@ -9,72 +9,186 @@ 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)
}
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)
}
}
/// Verkleinerte Libelle für Sperrbildschirm/CarPlay und die erweiterte
/// Dynamic Island dieselbe Optik wie `LevelBubble` in der App, aber ohne
/// deren Abhängigkeit auf `Shared/Models/LevelState.swift`, das in der
/// Extension nicht mitkompiliert wird.
private struct LevelBubbleGlyph: View {
let pitch: Double?
let roll: Double?
let isLevel: Bool
var diameter: CGFloat = 54
/// Bis zu welcher Neigung die Blase ausschlägt, wie in der App-Libelle.
private let range: Double = 6
/// Muss mit `LevelState.levelTolerance` übereinstimmen.
private let tolerance: Double = 0.5
private var hasReading: Bool { pitch != nil || roll != nil }
private var bubbleColor: Color {
guard let pitch, let roll else { return .white.opacity(0.4) }
let deviation = max(abs(pitch), abs(roll))
if deviation <= tolerance { return .green }
if deviation <= 2 { return .orange }
return .red
}
// Fixed non-changing properties about your activity go here!
var name: String
var body: some View {
let radius = diameter / 2
let bubble = diameter * 0.22
let travel = radius - bubble / 2 - 3
let offsetX = clamped(roll) / range * travel
let offsetY = clamped(pitch) / range * travel
let toleranceRadius = max(tolerance / range * travel, bubble * 0.55)
ZStack {
Circle()
.fill(Color.white.opacity(0.1))
Circle()
.strokeBorder(Color.white.opacity(0.3), lineWidth: 1)
Circle()
.strokeBorder(isLevel ? Color.green : Color.white.opacity(0.25),
lineWidth: isLevel ? 2 : 1)
.frame(width: toleranceRadius * 2, height: toleranceRadius * 2)
Circle()
.fill(bubbleColor)
.frame(width: bubble, height: bubble)
// Positiver Pitch heisst: Heck steht höher, die Blase wandert
// also nach oben wie bei der echten Wasserwaage in der App.
.offset(x: offsetX, y: -offsetY)
.opacity(hasReading ? 1 : 0.3)
}
.frame(width: diameter, height: diameter)
}
private func clamped(_ value: Double?) -> Double {
guard let value else { return 0 }
return min(max(value, -range), range)
}
}
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")
LevelBubbleGlyph(pitch: context.state.pitch, roll: context.state.roll,
isLevel: context.state.isLevel, diameter: 44)
}
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) {
LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel)
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
}