diff --git a/CamperMonitor/Bluetooth/BluetoothManager.swift b/CamperMonitor/Bluetooth/BluetoothManager.swift index d9a8cef..f27a945 100644 --- a/CamperMonitor/Bluetooth/BluetoothManager.swift +++ b/CamperMonitor/Bluetooth/BluetoothManager.swift @@ -517,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)) diff --git a/CamperMonitor/CamperMonitorApp.swift b/CamperMonitor/CamperMonitorApp.swift index 2aa5d11..8c92b90 100644 --- a/CamperMonitor/CamperMonitorApp.swift +++ b/CamperMonitor/CamperMonitorApp.swift @@ -43,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 } diff --git a/CamperMonitor/LiveActivity/LevelActivityManager.swift b/CamperMonitor/LiveActivity/LevelActivityManager.swift index fb0018f..6ffafbd 100644 --- a/CamperMonitor/LiveActivity/LevelActivityManager.swift +++ b/CamperMonitor/LiveActivity/LevelActivityManager.swift @@ -13,6 +13,11 @@ final class LevelActivityManager { private(set) var trackedDeviceID: UUID? @ObservationIgnored private var activity: Activity? + /// 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 } diff --git a/CamperMonitor/Store/DeviceStore.swift b/CamperMonitor/Store/DeviceStore.swift index b3259b9..80a8ce5 100644 --- a/CamperMonitor/Store/DeviceStore.swift +++ b/CamperMonitor/Store/DeviceStore.swift @@ -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) diff --git a/VanAligneiOS.xcodeproj/project.pbxproj b/VanAligneiOS.xcodeproj/project.pbxproj index dc8db19..ba243fc 100644 --- a/VanAligneiOS.xcodeproj/project.pbxproj +++ b/VanAligneiOS.xcodeproj/project.pbxproj @@ -22,9 +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 */; }; + AA0000000000000000000047 /* VanAligneiOSWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9DB55BAC3048B29B00CFD174 /* VanAligneiOSWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -45,17 +45,6 @@ /* 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; @@ -67,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 */ @@ -475,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; @@ -494,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; @@ -541,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; @@ -560,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; @@ -636,7 +636,7 @@ 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"; @@ -653,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; @@ -669,7 +669,7 @@ 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"; @@ -686,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; @@ -701,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; @@ -733,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; @@ -764,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; @@ -774,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; @@ -795,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; @@ -805,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; diff --git a/VanAligneiOSWidget/VanAligneiOSWidgetLiveActivity.swift b/VanAligneiOSWidget/VanAligneiOSWidgetLiveActivity.swift index 8246175..48811a6 100644 --- a/VanAligneiOSWidget/VanAligneiOSWidgetLiveActivity.swift +++ b/VanAligneiOSWidget/VanAligneiOSWidgetLiveActivity.swift @@ -27,6 +27,65 @@ private struct LevelIcon: View { } } +/// 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 + } + + 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: LevelActivityAttributes.self) { context in @@ -37,8 +96,8 @@ struct VanAligneiOSWidgetLiveActivity: Widget { } dynamicIsland: { context in DynamicIsland { DynamicIslandExpandedRegion(.leading) { - LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil) - .font(.title2) + LevelBubbleGlyph(pitch: context.state.pitch, roll: context.state.roll, + isLevel: context.state.isLevel, diameter: 44) } DynamicIslandExpandedRegion(.trailing) { VStack(alignment: .trailing, spacing: 2) { @@ -73,9 +132,7 @@ private struct LockScreenLevelView: View { var body: some View { HStack(spacing: 16) { - LevelIcon(isLevel: state.isLevel, hasReading: state.pitch != nil) - .font(.system(size: 32)) - .frame(width: 40) + LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel) VStack(alignment: .leading, spacing: 4) { Text(attributes.deviceName)