diff --git a/Config/VanControlCarPlay-Info.plist b/Config/VanControlCarPlay-Info.plist new file mode 100644 index 0000000..3349566 --- /dev/null +++ b/Config/VanControlCarPlay-Info.plist @@ -0,0 +1,31 @@ + + + + + UIBackgroundModes + + bluetooth-central + + NSLocationAlwaysAndWhenInUseUsageDescription + Damit die Nivellierung auf dem CarPlay-Display nur im Stand angezeigt wird. + NSLocationWhenInUseUsageDescription + Damit die Nivellierung auf dem CarPlay-Display nur im Stand angezeigt wird. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + CPTemplateApplicationSceneSessionRoleApplication + + + UISceneConfigurationName + CarPlay + UISceneDelegateClassName + CarPlaySceneDelegate + + + + + + diff --git a/Config/VanControlCarPlay.entitlements b/Config/VanControlCarPlay.entitlements new file mode 100644 index 0000000..d74cc08 --- /dev/null +++ b/Config/VanControlCarPlay.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.carplay-maps + + + diff --git a/VanControl.xcodeproj/project.pbxproj b/VanControl.xcodeproj/project.pbxproj index 17165af..f0fe135 100644 --- a/VanControl.xcodeproj/project.pbxproj +++ b/VanControl.xcodeproj/project.pbxproj @@ -745,6 +745,177 @@ }; name = Release; }; + AA0000000000000000000090 /* CarPlay */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG CARPLAY_DEMO $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = CarPlay; + }; + AA0000000000000000000091 /* CarPlay */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIcon-App2-Testflight"; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-App1-Testflight"; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CODE_SIGN_ENTITLEMENTS = Config/VanControlCarPlay.entitlements; + "CODE_SIGN_IDENTITY[sdk=iphonesimulator*]" = "-"; + "CODE_SIGN_IDENTITY[sdk=watchsimulator*]" = "-"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 2; + DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = P8J6283TT5; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Config/VanControlCarPlay-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "VanControl CarPlay"; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + 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 = NO; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1; + PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanControlCarPlay; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG CARPLAY_DEMO $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = CarPlay; + }; + AA0000000000000000000092 /* CarPlay */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + "CODE_SIGN_IDENTITY[sdk=iphonesimulator*]" = "-"; + "CODE_SIGN_IDENTITY[sdk=watchsimulator*]" = "-"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 3; + DEVELOPMENT_TEAM = P8J6283TT5; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "VanControl CarPlay"; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone."; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.s0.fototeddy.VanControlCarPlay; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1; + PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanControlCarPlay.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = CarPlay; + }; + AA0000000000000000000093 /* CarPlay */ = { + isa = XCBuildConfiguration; + buildSettings = { + "CODE_SIGN_IDENTITY[sdk=iphonesimulator*]" = "-"; + "CODE_SIGN_IDENTITY[sdk=watchsimulator*]" = "-"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 2; + DEVELOPMENT_TEAM = P8J6283TT5; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Config/VanControlComplication-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = Nivellierung; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.1; + PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanControlCarPlay.watchkitapp.levelwidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = CarPlay; + }; + AA0000000000000000000094 /* CarPlay */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + "CODE_SIGN_IDENTITY[sdk=iphonesimulator*]" = "-"; + "CODE_SIGN_IDENTITY[sdk=watchsimulator*]" = "-"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 2; + DEVELOPMENT_TEAM = P8J6283TT5; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = VanControlLiveActivity/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "VanControl Live Activity"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.1; + PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanControlCarPlay.VanControlLiveActivity; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = CarPlay; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -752,6 +923,7 @@ isa = XCConfigurationList; buildConfigurations = ( AA0000000000000000000012 /* Debug */, + AA0000000000000000000090 /* CarPlay */, AA0000000000000000000013 /* Release */, ); defaultConfigurationIsVisible = 0; @@ -761,6 +933,7 @@ isa = XCConfigurationList; buildConfigurations = ( AA0000000000000000000014 /* Debug */, + AA0000000000000000000091 /* CarPlay */, AA0000000000000000000015 /* Release */, ); defaultConfigurationIsVisible = 0; @@ -770,6 +943,7 @@ isa = XCConfigurationList; buildConfigurations = ( AA0000000000000000000023 /* Debug */, + AA0000000000000000000092 /* CarPlay */, AA0000000000000000000024 /* Release */, ); defaultConfigurationIsVisible = 0; @@ -779,6 +953,7 @@ isa = XCConfigurationList; buildConfigurations = ( AA0000000000000000000038 /* Debug */, + AA0000000000000000000093 /* CarPlay */, AA0000000000000000000039 /* Release */, ); defaultConfigurationIsVisible = 0; @@ -788,6 +963,7 @@ isa = XCConfigurationList; buildConfigurations = ( AA0000000000000000000066 /* Debug */, + AA0000000000000000000094 /* CarPlay */, AA0000000000000000000067 /* Release */, ); defaultConfigurationIsVisible = 0; diff --git a/VanControl.xcodeproj/xcshareddata/xcschemes/VanControl CarPlay.xcscheme b/VanControl.xcodeproj/xcshareddata/xcschemes/VanControl CarPlay.xcscheme new file mode 100644 index 0000000..7706ba8 --- /dev/null +++ b/VanControl.xcodeproj/xcshareddata/xcschemes/VanControl CarPlay.xcscheme @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/VanControl/CarPlay/CarPlayLevelScreen.swift b/VanControl/CarPlay/CarPlayLevelScreen.swift new file mode 100644 index 0000000..d5324c6 --- /dev/null +++ b/VanControl/CarPlay/CarPlayLevelScreen.swift @@ -0,0 +1,91 @@ +#if CARPLAY_DEMO +import SwiftUI + +/// Die Libelle auf dem Fahrzeugdisplay. +/// +/// Quer geteilt: links die Blase, rechts in Worten, was zu tun ist. Beim +/// Rangieren wird abwechselnd auf Display und Umfeld geschaut – deshalb steht +/// die Aussage in einem Satz da, statt sich aus zwei Zahlen ergeben zu müssen. +/// +/// Die Groessen sind auf die 400 × 240 **Punkte** ausgelegt, die CarPlay +/// liefert – nicht auf die 800 × 480 Pixel. Das ist die Falle: In Pixeln +/// gedachte Schriftgroessen sind auf dem Fahrzeugdisplay doppelt zu gross und +/// schneiden den Text ab. +struct CarPlayLevelScreen: View { + @Environment(CarPlayLevelSource.self) private var source + + private var state: LevelState { + LevelState(pitch: source.pitch, roll: source.roll) + } + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + + if CarPlaySpeedGate.shared.isStationary { + levelling + } else { + driving + } + } + .onAppear { CarPlaySpeedGate.shared.start() } + .onDisappear { CarPlaySpeedGate.shared.stop() } + } + + private var levelling: some View { + HStack(spacing: 18) { + LevelBubble(pitch: source.pitch, roll: source.roll) + + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + let lines = state.shortInstructions + ForEach(lines.isEmpty ? ["Keine Verbindung"] : lines, id: \.self) { line in + Text(line) + .font(.system(size: 20, weight: .semibold, design: .rounded)) + .foregroundStyle(state.isLevel ? .green : .white) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + } + + HStack(spacing: 16) { + tile("Längs", LevelDirectionFormatting.pitchTile(source.pitch)) + tile("Quer", LevelDirectionFormatting.rollTile(source.roll)) + } + + if source.isSimulated { + // Ohne diesen Hinweis wäre ein Screenshot aus dem Simulator + // eine Behauptung über Messwerte, die es nicht gab. + Text("Simulierte Werte") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.orange) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(16) + } + + private var driving: some View { + VStack(spacing: 10) { + Image(systemName: "car.fill") + .font(.system(size: 30)) + Text("Nivellierung nur im Stand") + .font(.system(size: 20, weight: .semibold, design: .rounded)) + } + .foregroundStyle(.white.opacity(0.7)) + } + + private func tile(_ label: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(label) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + Text(value) + .font(.system(size: 22, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + } + } +} +#endif diff --git a/VanControl/CarPlay/CarPlayLevelSource.swift b/VanControl/CarPlay/CarPlayLevelSource.swift new file mode 100644 index 0000000..1685766 --- /dev/null +++ b/VanControl/CarPlay/CarPlayLevelSource.swift @@ -0,0 +1,105 @@ +#if CARPLAY_DEMO +import Foundation +import Observation + +/// Woher die CarPlay-Libelle ihre Werte bekommt. +/// +/// Im Simulator gibt es kein Bluetooth und damit keinen Neigungsmesser. Für +/// die Demo läuft deshalb ein simulierter Verlauf: ein Fahrzeug, das langsam +/// auf die Keile rollt, gerade wird und wieder abrutscht. Das reicht, um die +/// Anzeige zu beurteilen – und für Screenshots, die zeigen, was die Funktion +/// tut. +/// +/// Für gezielte Aufnahmen lassen sich feste Werte über die Umgebung setzen: +/// `CARPLAY_DEMO_PITCH` und `CARPLAY_DEMO_ROLL` (in Grad). Dann steht die +/// Blase still und der Screenshot ist reproduzierbar. +/// +/// Am Fahrzeug speist stattdessen der `BluetoothManager` über ``apply(_:)`` +/// ein; die Demo-Schleife läuft dann nicht. +@Observable +final class CarPlayLevelSource { + static let shared = CarPlayLevelSource() + + private(set) var pitch: Double? + private(set) var roll: Double? + + /// Ob die Werte simuliert sind. Die Anzeige weist darauf hin – ein + /// Screenshot ohne diesen Hinweis wäre gegenüber Apple nicht ehrlich. + private(set) var isSimulated = false + + @ObservationIgnored private var timer: Timer? + @ObservationIgnored private var phase: Double = 0 + @ObservationIgnored private lazy var isSequenced = + ProcessInfo.processInfo.environment["CARPLAY_DEMO_SEQUENCE"] != nil + + private init() {} + + /// Zehn Aktualisierungen pro Sekunde – schnell genug, dass die Blase der + /// Bewegung des Fahrzeugs folgt statt ihr nachzulaufen. + private static let updateInterval: TimeInterval = 0.1 + + func start() { + guard timer == nil else { return } + + let environment = ProcessInfo.processInfo.environment + if let fixedPitch = environment["CARPLAY_DEMO_PITCH"].flatMap(Double.init), + let fixedRoll = environment["CARPLAY_DEMO_ROLL"].flatMap(Double.init) { + pitch = fixedPitch + roll = fixedRoll + isSimulated = true + return + } + + isSimulated = true + let timer = Timer.scheduledTimer(withTimeInterval: Self.updateInterval, repeats: true) { [weak self] _ in + self?.step() + } + RunLoop.main.add(timer, forMode: .common) + self.timer = timer + } + + func stop() { + timer?.invalidate() + timer = nil + } + + /// Echte Messwerte vom Neigungsmesser. Sobald die kommen, ist nichts mehr + /// simuliert. + func apply(_ state: LevelState) { + stop() + isSimulated = false + pitch = state.pitch + roll = state.roll + } + + /// Feste Zustände im Wechsel, je sieben Sekunden – für Screenshots, die + /// beide Fälle zuverlässig zeigen statt auf den richtigen Moment einer + /// Schwingung zu warten. + private static let sequence: [(pitch: Double, roll: Double)] = [ + (1.8, -0.9), // deutlich schief + (0.2, -0.1), // ausgerichtet + ] + + /// Ein Fahrzeug, das sich über gut zwanzig Sekunden einpendelt und wieder + /// abweicht. Zwei unterschiedlich schnelle Schwingungen, damit Längs- und + /// Querneigung nicht im Gleichtakt laufen – das sähe mechanisch aus. + private func step() { + phase += Self.updateInterval + + if isSequenced { + // Dritte Phase: die Sperre waehrend der Fahrt. + let step = Int(phase / 7) % (Self.sequence.count + 1) + if step == Self.sequence.count { + CarPlaySpeedGate.shared.setDemoDriving(true) + } else { + CarPlaySpeedGate.shared.setDemoDriving(false) + (pitch, roll) = Self.sequence[step] + } + return + } + + pitch = 3.2 * sin(phase / 3.4) + roll = 2.1 * sin(phase / 2.1 + 0.7) + } +} +#endif diff --git a/VanControl/CarPlay/CarPlayPreviewHost.swift b/VanControl/CarPlay/CarPlayPreviewHost.swift new file mode 100644 index 0000000..3be1904 --- /dev/null +++ b/VanControl/CarPlay/CarPlayPreviewHost.swift @@ -0,0 +1,40 @@ +import SwiftUI + +/// Zeigt die CarPlay-Ansicht im iPhone-Simulator, in den Maßen, die CarPlay +/// tatsächlich liefert (400 × 240 Punkte, also 800 × 480 Pixel). +/// +/// Nur zum Beurteilen des Layouts, solange der CarPlay-Simulator selbst nicht +/// zur Verfügung steht. Ohne `CARPLAY_DEMO_PREVIEW` in der Umgebung passiert +/// nichts, und ohne das `CARPLAY_DEMO`-Flag existiert der Code gar nicht. +extension View { + @ViewBuilder + func carPlayPreviewOverlay() -> some View { + #if CARPLAY_DEMO + if ProcessInfo.processInfo.environment["CARPLAY_DEMO_PREVIEW"] != nil { + CarPlayPreviewHost() + } else { + self + } + #else + self + #endif + } +} + +#if CARPLAY_DEMO +struct CarPlayPreviewHost: View { + var body: some View { + // Ohne Beiwerk und exakt zentriert: so laesst sich der Screenshot + // verlustfrei auf den CarPlay-Bereich zuschneiden. + VStack { + CarPlayLevelScreen() + .environment(CarPlayLevelSource.shared) + .frame(width: 400, height: 240) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) + .ignoresSafeArea() + .onAppear { CarPlayLevelSource.shared.start() } + } +} +#endif diff --git a/VanControl/CarPlay/CarPlaySceneDelegate.swift b/VanControl/CarPlay/CarPlaySceneDelegate.swift new file mode 100644 index 0000000..ce48503 --- /dev/null +++ b/VanControl/CarPlay/CarPlaySceneDelegate.swift @@ -0,0 +1,96 @@ +#if CARPLAY_DEMO +import CarPlay +import OSLog +import SwiftUI + +/// Einstiegspunkt der CarPlay-Szene. +/// +/// Mit dem Navigations-Entitlement liefert CarPlay ein eigenes `CPWindow`, in +/// das frei gezeichnet werden darf – anders als bei den Template-Kategorien, +/// die nur vorgegebene Bausteine erlauben und höchstens alle zehn Sekunden +/// aktualisieren. Genau deshalb hängt hier die Libelle drin: Beim Rangieren +/// muss sie der Bewegung des Fahrzeugs folgen, nicht ihr hinterherlaufen. +/// +/// Das `CPMapTemplate` darüber ist nur die Bedienleiste. Es bleibt bewusst +/// leer: Auf dem Stellplatz gibt es nichts zu tippen, es gibt nur etwas +/// abzulesen. +@objc(CarPlaySceneDelegate) +final class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPMapTemplateDelegate { + private var interfaceController: CPInterfaceController? + + /// Ohne eigenes Logging ist beim Debuggen nicht zu unterscheiden, ob die + /// Szene gar nicht verbindet oder ob CarPlay das Template ablehnt. + private static let log = Logger(subsystem: "de.s0.fototeddy.VanControl", category: "CarPlay") + + func templateApplicationScene( + _ templateApplicationScene: CPTemplateApplicationScene, + didConnect interfaceController: CPInterfaceController, + to window: CPWindow + ) { + Self.log.notice("didConnect: Szene verbunden, Fenster \(NSCoder.string(for: window.bounds.size), privacy: .public)") + self.interfaceController = interfaceController + + let root = CarPlayLevelScreen() + .environment(CarPlayLevelSource.shared) + let host = UIHostingController(rootView: root) + host.view.backgroundColor = .black + window.rootViewController = host + window.makeKeyAndVisible() + + CarPlayLevelSource.shared.start() + + // Bewusst nackt konfiguriert: iOS 26 laesst den CarPlayTemplateUIHost + // beim Aufbau der Navigationsleiste ueber den Share-Button stolpern + // (`_updateShareButtonVisibility` -> unrecognized selector), sobald die + // Leiste erzwungen wird. Ohne Buttons und mit der Standard-Automatik + // gibt es nichts zu konfigurieren – und auf dem Stellplatz gibt es + // ohnehin nichts zu tippen. + let map = CPMapTemplate() + map.mapButtons = [] + map.leadingNavigationBarButtons = [] + map.trailingNavigationBarButtons = [] + map.hidesButtonsWithNavigationBar = true + map.mapDelegate = self + interfaceController.setRootTemplate(map, animated: false) { success, error in + if let error { + Self.log.error("setRootTemplate abgelehnt: \(error.localizedDescription, privacy: .public)") + } else { + Self.log.notice("setRootTemplate ok: \(success, privacy: .public)") + } + } + } + + func templateApplicationScene( + _ templateApplicationScene: CPTemplateApplicationScene, + didDisconnectInterfaceController interfaceController: CPInterfaceController, + from window: CPWindow + ) { + Self.log.notice("didDisconnect: Szene getrennt") + self.interfaceController = nil + window.rootViewController = nil + CarPlayLevelSource.shared.stop() + } + // MARK: - Zielteilung + + // CarPlay bietet seit iOS 26 an, ein Navigationsziel an das Fahrzeug zu + // übergeben. Wir haben keine Ziele – aber das Protokoll vollständig zu + // bedienen ist Teil davon, eine korrekte Navigations-App zu sein. Der + // Verdacht dabei: Apples Template-Host löst seinen Sharing-Delegate nur + // dann sauber auf, wenn die App diese Methoden anbietet. + + @available(iOS 26.4, *) + func mapTemplate(_ mapTemplate: CPMapTemplate, willShareDestinationFor trip: CPTrip) { + Self.log.notice("willShareDestination – die App teilt keine Ziele") + } + + @available(iOS 26.4, *) + func mapTemplate(_ mapTemplate: CPMapTemplate, didShareDestinationFor trip: CPTrip) { + Self.log.notice("didShareDestination") + } + + @available(iOS 26.4, *) + func mapTemplate(_ mapTemplate: CPMapTemplate, didFailToShareDestinationFor trip: CPTrip, error: Error) { + Self.log.error("didFailToShareDestination: \(error.localizedDescription, privacy: .public)") + } +} +#endif diff --git a/VanControl/CarPlay/CarPlaySpeedGate.swift b/VanControl/CarPlay/CarPlaySpeedGate.swift new file mode 100644 index 0000000..31eefa0 --- /dev/null +++ b/VanControl/CarPlay/CarPlaySpeedGate.swift @@ -0,0 +1,77 @@ +#if CARPLAY_DEMO +import CoreLocation +import Observation + +/// Sperrt die Libelle, sobald das Fahrzeug schneller als Schrittgeschwindigkeit +/// fährt. +/// +/// Ausrichten passiert im Stand oder im Zentimeterbereich davor. Eine Anzeige, +/// die während der Fahrt etwas zu lesen gäbe, wäre Ablenkung ohne Nutzen – also +/// gibt es sie dort nicht. Die Grenze ist bewusst technisch erzwungen und nicht +/// bloss eine Zusage im Text. +@Observable +final class CarPlaySpeedGate: NSObject, CLLocationManagerDelegate { + static let shared = CarPlaySpeedGate() + + /// Fünf km/h: schneller als Rangieren, langsamer als jede Verkehrssituation, + /// in der jemand aufs Display sehen dürfte. + static let limit: CLLocationSpeed = 5.0 / 3.6 + + private(set) var isStationary = true + private(set) var speed: CLLocationSpeed? + + @ObservationIgnored private let manager = CLLocationManager() + + private override init() { + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters + } + + func start() { + // Fuer Screenshots laesst sich die Geschwindigkeit vorgeben (in km/h); + // eine simulierte GPS-Fahrt nur fuer ein Standbild waere unverhaeltnis- + // maessig. Ohne die Variable zaehlt ausschliesslich der echte Sensor. + if let forced = ProcessInfo.processInfo.environment["CARPLAY_DEMO_SPEED"].flatMap(Double.init) { + speed = forced / 3.6 + isStationary = forced / 3.6 <= Self.limit + return + } + manager.requestWhenInUseAuthorization() + manager.startUpdatingLocation() + } + + /// Nur für die Demo-Sequenz: erzwingt den Fahrzustand, damit sich die + /// Sperre zeigen lässt, ohne eine GPS-Fahrt zu simulieren. + func setDemoDriving(_ driving: Bool) { + speed = driving ? 30 / 3.6 : 0 + isStationary = !driving + } + + func stop() { + manager.stopUpdatingLocation() + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let location = locations.last else { return } + + // CoreLocation meldet im Stand eine negative Geschwindigkeit: der Wert + // ist dann schlicht unbekannt. Das als "fährt" zu werten, würde die + // Funktion genau dort abschalten, wo sie gebraucht wird – auf dem + // Stellplatz, oft unter Bäumen mit schlechtem Empfang. + guard location.speed >= 0 else { + speed = nil + isStationary = true + return + } + + speed = location.speed + isStationary = location.speed <= Self.limit + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + speed = nil + isStationary = true + } +} +#endif diff --git a/VanControl/VanControlApp.swift b/VanControl/VanControlApp.swift index 12422e9..6445498 100644 --- a/VanControl/VanControlApp.swift +++ b/VanControl/VanControlApp.swift @@ -27,6 +27,7 @@ struct VanControlApp: App { .environment(watch) .environment(levelActivity) .task { watch.activate() } + .carPlayPreviewOverlay() } .onChange(of: scenePhase) { _, phase in // Im Hintergrund darf ohne Service-Filter ohnehin nicht gescannt