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