From 7cd7a1eb25aeb02f2ff2a35c236eb16c6827e31b Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Mon, 25 Nov 2024 20:11:58 -0800 Subject: [PATCH] Initializes CarPlay (#368) * feat: in progressing setting up basics for car play * feat: basic CarPlay init * feat: basic CarPlay init * feat: basic CarPlay init * feat: basic CarPlay init * added carplay guide page & minor updates * added carplay guide page & minor updates * added carplay guide page & minor updates --- Package.swift | 16 +++ apple/DemoApp/Demo/AppEnvironment.swift | 117 +++++++++++++++++ apple/DemoApp/Demo/CarPlaySceneDelegate.swift | 56 ++++++++ apple/DemoApp/Demo/ConfigurationView.swift | 14 -- apple/DemoApp/Demo/DemoApp.swift | 9 ++ apple/DemoApp/Demo/DemoNavigationView.swift | 120 +++--------------- ...elegate.swift => NavigationDelegate.swift} | 0 apple/DemoApp/Ferrostar Demo-Info.plist | 41 ++++-- apple/DemoApp/Ferrostar Demo.entitlements | 8 ++ .../Ferrostar Demo.xcodeproj/project.pbxproj | 34 +++-- .../CarPlayNavigationView.swift | 75 +++++++++++ .../Extensions/NavigationStateCarPlay.swift | 16 +++ .../Extensions/UIHostingControllerInit.swift | 11 ++ .../FerrostarCarPlayManager.swift | 100 +++++++++++++++ guide/src/SUMMARY.md | 1 + guide/src/ios-carplay.md | 5 + 16 files changed, 488 insertions(+), 135 deletions(-) create mode 100644 apple/DemoApp/Demo/AppEnvironment.swift create mode 100644 apple/DemoApp/Demo/CarPlaySceneDelegate.swift delete mode 100644 apple/DemoApp/Demo/ConfigurationView.swift rename apple/DemoApp/Demo/{Navigation Delegate.swift => NavigationDelegate.swift} (100%) create mode 100644 apple/DemoApp/Ferrostar Demo.entitlements create mode 100644 apple/Sources/FerrostarCarPlayUI/CarPlayNavigationView.swift create mode 100644 apple/Sources/FerrostarCarPlayUI/Extensions/NavigationStateCarPlay.swift create mode 100644 apple/Sources/FerrostarCarPlayUI/Extensions/UIHostingControllerInit.swift create mode 100644 apple/Sources/FerrostarCarPlayUI/FerrostarCarPlayManager.swift create mode 100644 guide/src/ios-carplay.md diff --git a/Package.swift b/Package.swift index d9666f69..cfc09c54 100644 --- a/Package.swift +++ b/Package.swift @@ -52,12 +52,17 @@ let package = Package( targets: [ "FerrostarMapLibreUI", "FerrostarSwiftUI", + "FerrostarCarPlayUI", ] // TODO: Remove FerrostarSwiftUI from FerrostarMapLibreUI once we can fix the demo app swift package config (broken in Xcode 15.3) ), .library( name: "FerrostarSwiftUI", targets: ["FerrostarSwiftUI"] ), + .library( + name: "FerrostarCarPlayUI", + targets: ["FerrostarCarPlayUI"] + ), ], dependencies: [ maplibreSwiftUIDSLPackage, @@ -68,6 +73,17 @@ let package = Package( ], targets: [ binaryTarget, + .target( + name: "FerrostarCarPlayUI", + dependencies: [ + .target(name: "FerrostarCore"), + .target(name: "FerrostarSwiftUI"), + .target(name: "FerrostarMapLibreUI"), + .product(name: "MapLibreSwiftDSL", package: "swiftui-dsl"), + .product(name: "MapLibreSwiftUI", package: "swiftui-dsl"), + ], + path: "apple/Sources/FerrostarCarPlayUI" + ), .target( name: "FerrostarCore", dependencies: [.target(name: "FerrostarCoreFFI")], diff --git a/apple/DemoApp/Demo/AppEnvironment.swift b/apple/DemoApp/Demo/AppEnvironment.swift new file mode 100644 index 00000000..78b395d0 --- /dev/null +++ b/apple/DemoApp/Demo/AppEnvironment.swift @@ -0,0 +1,117 @@ +import CoreLocation +import FerrostarCore +import FerrostarCoreFFI +import SwiftUI + +enum AppDefaults { + static let initialLocation = CLLocation(latitude: 37.332726, longitude: -122.031790) + static let mapStyleURL = + URL(string: "https://tiles.stadiamaps.com/styles/outdoors.json?api_key=\(APIKeys.shared.stadiaMapsAPIKey)")! +} + +enum DemoAppError: Error { + case noUserLocation + case noRoutes + case other(Error) +} + +/// This is a shared core where ferrostar lives +class AppEnvironment: ObservableObject { + var locationProvider: LocationProviding + @Published var ferrostarCore: FerrostarCore + @Published var spokenInstructionObserver: SpokenInstructionObserver + + let navigationDelegate = NavigationDelegate() + + init(initialLocation: CLLocation = AppDefaults.initialLocation) { + let simulated = SimulatedLocationProvider(location: initialLocation) + simulated.warpFactor = 2 + locationProvider = simulated + + // Set up the a standard Apple AV Speech Synth. + spokenInstructionObserver = .initAVSpeechSynthesizer() + + // Configure the navigation session. + // You have a lot of flexibility here based on your use case. + let config = SwiftNavigationControllerConfig( + stepAdvance: .relativeLineStringDistance(minimumHorizontalAccuracy: 32, automaticAdvanceDistance: 10), + routeDeviationTracking: .staticThreshold(minimumHorizontalAccuracy: 25, maxAcceptableDeviation: 20), + snappedLocationCourseFiltering: .snapToRoute + ) + + ferrostarCore = try! FerrostarCore( + valhallaEndpointUrl: URL( + string: "https://api.stadiamaps.com/route/v1?api_key=\(APIKeys.shared.stadiaMapsAPIKey)" + )!, + profile: "bicycle", + locationProvider: locationProvider, + navigationControllerConfig: config, + options: ["costing_options": ["bicycle": ["use_roads": 0.2]]], + // This is how you can set up annotation publishing; + // We provide "extended OSRM" support out of the box, + // but this is fully extendable! + annotation: AnnotationPublisher.valhallaExtendedOSRM() + ) + + // NOTE: Not all applications will need a delegate. Read the NavigationDelegate documentation for details. + ferrostarCore.delegate = navigationDelegate + + // Initialize text-to-speech; note that this is NOT automatic. + // You must set a spokenInstructionObserver. + // Fortunately, this is pretty easy with the provided class + // backed by AVSpeechSynthesizer. + // You can customize the instance it further as needed, + // or replace with your own. + ferrostarCore.spokenInstructionObserver = spokenInstructionObserver + } + + func getRoutes() async throws -> [Route] { + guard let userLocation = locationProvider.lastLocation else { + throw DemoAppError.noUserLocation + } + + let waypoints = locations.map { Waypoint( + coordinate: GeographicCoordinate(lat: $0.coordinate.latitude, lng: $0.coordinate.longitude), + kind: .break + ) } + + let routes = try await ferrostarCore.getRoutes(initialLocation: userLocation, + waypoints: waypoints) + + guard let route = routes.first else { + throw DemoAppError.noRoutes + } + + print("DemoApp: successfully fetched routes") + + if let simulated = locationProvider as? SimulatedLocationProvider { + // This configures the simulator to the desired route. + // The ferrostarCore.startNavigation will still start the location + // provider/simulator. + simulated + .lastLocation = UserLocation(clCoordinateLocation2D: route.geometry.first!.clLocationCoordinate2D) + print("DemoApp: setting initial location") + } + + return routes + } + + func startNavigation(route: Route) throws { + if let simulated = locationProvider as? SimulatedLocationProvider { + // This configures the simulator to the desired route. + // The ferrostarCore.startNavigation will still start the location + // provider/simulator. + try simulated.setSimulatedRoute(route, resampleDistance: 5) + print("DemoApp: setting route to be simulated") + } + + // Starts the navigation state machine. + // It's worth having a look through the parameters, + // as most of the configuration happens here. + try ferrostarCore.startNavigation(route: route) + } + + func stopNavigation() { + ferrostarCore.stopNavigation() + } +} diff --git a/apple/DemoApp/Demo/CarPlaySceneDelegate.swift b/apple/DemoApp/Demo/CarPlaySceneDelegate.swift new file mode 100644 index 00000000..9a88d029 --- /dev/null +++ b/apple/DemoApp/Demo/CarPlaySceneDelegate.swift @@ -0,0 +1,56 @@ +import CarPlay +import FerrostarCarPlayUI +import FerrostarCore +import SwiftUI +import UIKit + +class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { + // Get the AppDelegate associated with the SwiftUI App/@main as the type you defined it as. + @UIApplicationDelegateAdaptor(DemoAppDelegate.self) var appDelegate + + private var ferrostarManager: FerrostarCarPlayManager? + + func configure() { + guard ferrostarManager == nil else { return } + + ferrostarManager = FerrostarCarPlayManager( + ferrostarCore: appDelegate.appEnvironment.ferrostarCore, + styleURL: AppDefaults.mapStyleURL + ) + } + + func templateApplicationScene( + _ templateApplicationScene: CPTemplateApplicationScene, + didConnect interfaceController: CPInterfaceController, + to window: CPWindow + ) { + configure() + ferrostarManager!.templateApplicationScene(templateApplicationScene, + didConnect: interfaceController, + to: window) + } +} + +extension CarPlaySceneDelegate: CPTemplateApplicationDashboardSceneDelegate { + func templateApplicationDashboardScene(_: CPTemplateApplicationDashboardScene, + didConnect _: CPDashboardController, + to _: UIWindow) {} + + func templateApplicationDashboardScene(_: CPTemplateApplicationDashboardScene, + didDisconnect _: CPDashboardController, + from _: UIWindow) {} +} + +extension CarPlaySceneDelegate: CPTemplateApplicationInstrumentClusterSceneDelegate { + // swiftlint:disable identifier_name vertical_parameter_alignment + func templateApplicationInstrumentClusterScene( + _: CPTemplateApplicationInstrumentClusterScene, + didConnect _: CPInstrumentClusterController + ) {} + + func templateApplicationInstrumentClusterScene( + _: CPTemplateApplicationInstrumentClusterScene, + didDisconnectInstrumentClusterController _: CPInstrumentClusterController + ) {} + // swiftlint:enable identifier_name vertical_parameter_alignment +} diff --git a/apple/DemoApp/Demo/ConfigurationView.swift b/apple/DemoApp/Demo/ConfigurationView.swift deleted file mode 100644 index 0bfa1c50..00000000 --- a/apple/DemoApp/Demo/ConfigurationView.swift +++ /dev/null @@ -1,14 +0,0 @@ -import SwiftUI - -struct ConfigurationView: View { - var body: some View { - VStack(spacing: 15) { - Text("TODO basic config for demo") - } - .navigationTitle("Config") - } -} - -#Preview { - ConfigurationView() -} diff --git a/apple/DemoApp/Demo/DemoApp.swift b/apple/DemoApp/Demo/DemoApp.swift index 42d39d92..affe909c 100644 --- a/apple/DemoApp/Demo/DemoApp.swift +++ b/apple/DemoApp/Demo/DemoApp.swift @@ -1,10 +1,19 @@ import SwiftUI +// This AppDelegate setup is an easy way to share your environment with CarPlay +class DemoAppDelegate: NSObject, UIApplicationDelegate { + let appEnvironment = AppEnvironment() +} + @main struct DemoApp: App { + @UIApplicationDelegateAdaptor(DemoAppDelegate.self) private var appDelegate: DemoAppDelegate + var body: some Scene { WindowGroup { DemoNavigationView() + .environmentObject(appDelegate.appEnvironment) + .environmentObject(appDelegate.appEnvironment.ferrostarCore) } } } diff --git a/apple/DemoApp/Demo/DemoNavigationView.swift b/apple/DemoApp/Demo/DemoNavigationView.swift index 2f8ecfb3..a08a1dd7 100644 --- a/apple/DemoApp/Demo/DemoNavigationView.swift +++ b/apple/DemoApp/Demo/DemoNavigationView.swift @@ -8,18 +8,9 @@ import MapLibreSwiftDSL import MapLibreSwiftUI import SwiftUI -let style = URL(string: "https://tiles.stadiamaps.com/styles/outdoors.json?api_key=\(APIKeys.shared.stadiaMapsAPIKey)")! -private let initialLocation = CLLocation(latitude: 37.332726, - longitude: -122.031790) - struct DemoNavigationView: View { - private let navigationDelegate = NavigationDelegate() - // NOTE: This is probably not ideal but works for demo purposes. - // This causes a thread performance checker warning log. - @State private var spokenInstructionObserver = SpokenInstructionObserver.initAVSpeechSynthesizer() - - private var locationProvider: LocationProviding - @ObservedObject private var ferrostarCore: FerrostarCore + @EnvironmentObject private var appEnvironment: AppEnvironment + @EnvironmentObject private var ferrostarCore: FerrostarCore @State private var isFetchingRoutes = false @State private var routes: [Route]? @@ -32,64 +23,25 @@ struct DemoNavigationView: View { } } - @State private var camera: MapViewCamera = .center(initialLocation.coordinate, zoom: 14) - @State private var snappedCamera = true - - init() { - let simulated = SimulatedLocationProvider(location: initialLocation) - simulated.warpFactor = 2 - locationProvider = simulated - - // Configure the navigation session. - // You have a lot of flexibility here based on your use case. - let config = SwiftNavigationControllerConfig( - stepAdvance: .relativeLineStringDistance(minimumHorizontalAccuracy: 32, automaticAdvanceDistance: 10), - routeDeviationTracking: .staticThreshold(minimumHorizontalAccuracy: 25, maxAcceptableDeviation: 20), - snappedLocationCourseFiltering: .snapToRoute - ) - - ferrostarCore = try! FerrostarCore( - valhallaEndpointUrl: URL( - string: "https://api.stadiamaps.com/route/v1?api_key=\(APIKeys.shared.stadiaMapsAPIKey)" - )!, - profile: "bicycle", - locationProvider: locationProvider, - navigationControllerConfig: config, - options: ["costing_options": ["bicycle": ["use_roads": 0.2]]], - // This is how you can set up annotation publishing; - // We provide "extended OSRM" support out of the box, - // but this is fully extendable! - annotation: AnnotationPublisher.valhallaExtendedOSRM() - ) - // NOTE: Not all applications will need a delegate. Read the NavigationDelegate documentation for details. - ferrostarCore.delegate = navigationDelegate - - // Initialize text-to-speech; note that this is NOT automatic. - // You must set a spokenInstructionObserver. - // Fortunately, this is pretty easy with the provided class - // backed by AVSpeechSynthesizer. - // You can customize the instance it further as needed, - // or replace with your own. - ferrostarCore.spokenInstructionObserver = spokenInstructionObserver - } + @State private var camera: MapViewCamera = .center(AppDefaults.initialLocation.coordinate, zoom: 14) var body: some View { - let locationServicesEnabled = locationProvider.authorizationStatus == .authorizedAlways - || locationProvider.authorizationStatus == .authorizedWhenInUse + let locationServicesEnabled = appEnvironment.locationProvider.authorizationStatus == .authorizedAlways + || appEnvironment.locationProvider.authorizationStatus == .authorizedWhenInUse NavigationStack { DynamicallyOrientingNavigationView( - styleURL: style, + styleURL: AppDefaults.mapStyleURL, camera: $camera, - navigationState: ferrostarCore.state, - isMuted: spokenInstructionObserver.isMuted, - onTapMute: spokenInstructionObserver.toggleMute, + navigationState: appEnvironment.ferrostarCore.state, + isMuted: appEnvironment.spokenInstructionObserver.isMuted, + onTapMute: appEnvironment.spokenInstructionObserver.toggleMute, onTapExit: { stopNavigation() }, makeMapContent: { let source = ShapeSource(identifier: "userLocation") { // Demonstrate how to add a dynamic overlay; // also incidentally shows the extent of puck lag - if let userLocation = locationProvider.lastLocation { + if let userLocation = appEnvironment.locationProvider.lastLocation { MLNPointFeature(coordinate: userLocation.clLocation.coordinate) } } @@ -129,7 +81,7 @@ struct DemoNavigationView: View { Task { do { isFetchingRoutes = true - try await startNavigation() + try startNavigation() isFetchingRoutes = false } catch { isFetchingRoutes = false @@ -163,66 +115,32 @@ struct DemoNavigationView: View { // MARK: Conveniences func getRoutes() async { - guard let userLocation = locationProvider.lastLocation else { - print("No user location") - return - } - do { - let waypoints = locations.map { Waypoint( - coordinate: GeographicCoordinate(lat: $0.coordinate.latitude, lng: $0.coordinate.longitude), - kind: .break - ) } - routes = try await ferrostarCore.getRoutes(initialLocation: userLocation, - waypoints: waypoints) - - print("DemoApp: successfully fetched a route") - - if let simulated = locationProvider as? SimulatedLocationProvider, let route = routes?.first { - // This configures the simulator to the desired route. - // The ferrostarCore.startNavigation will still start the location - // provider/simulator. - simulated - .lastLocation = UserLocation(clCoordinateLocation2D: route.geometry.first!.clLocationCoordinate2D) - print("DemoApp: setting initial location") - } + routes = try await appEnvironment.getRoutes() } catch { - print("DemoApp: error fetching route: \(error)") - errorMessage = "\(error)" + print("DemoApp: error getting routes: \(error)") } } - func startNavigation() async throws { + func startNavigation() throws { guard let route = routes?.first else { print("DemoApp: No route") return } - if let simulated = locationProvider as? SimulatedLocationProvider { - // This configures the simulator to the desired route. - // The ferrostarCore.startNavigation will still start the location - // provider/simulator. - try simulated.setSimulatedRoute(route, resampleDistance: 5) - print("DemoApp: setting route to be simulated") - } - - // Starts the navigation state machine. - // It's worth having a look through the parameters, - // as most of the configuration happens here. - try ferrostarCore.startNavigation(route: route) - + try appEnvironment.startNavigation(route: route) preventAutoLock() } func stopNavigation() { - ferrostarCore.stopNavigation() - camera = .center(initialLocation.coordinate, zoom: 14) + appEnvironment.stopNavigation() + camera = .center(AppDefaults.initialLocation.coordinate, zoom: 14) allowAutoLock() } var locationLabel: String { - guard let userLocation = locationProvider.lastLocation else { - return "No location - authed as \(locationProvider.authorizationStatus)" + guard let userLocation = appEnvironment.locationProvider.lastLocation else { + return "No location - authed as \(appEnvironment.locationProvider.authorizationStatus)" } return "±\(Int(userLocation.horizontalAccuracy))m accuracy" diff --git a/apple/DemoApp/Demo/Navigation Delegate.swift b/apple/DemoApp/Demo/NavigationDelegate.swift similarity index 100% rename from apple/DemoApp/Demo/Navigation Delegate.swift rename to apple/DemoApp/Demo/NavigationDelegate.swift diff --git a/apple/DemoApp/Ferrostar Demo-Info.plist b/apple/DemoApp/Ferrostar Demo-Info.plist index 7ac258d9..f28c1281 100644 --- a/apple/DemoApp/Ferrostar Demo-Info.plist +++ b/apple/DemoApp/Ferrostar Demo-Info.plist @@ -1,12 +1,37 @@ - - + + UIBackgroundModes + + audio + location + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + CPTemplateApplicationSceneSessionRoleApplication + + + UISceneClassName + CPTemplateApplicationScene + UISceneConfigurationName + CarPlay + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).CarPlaySceneDelegate + + + + + NSLocationWhenInUseUsageDescription + Your precise location is used to calculate turn-by-turn directions, show your location on the map, and help improve the map. + NSLocationTemporaryUsageDescriptionDictionary + + AllowPreciseForNavOnce + Please enable precise location. Turn-by-turn directions only work when precise location data is available. + + diff --git a/apple/DemoApp/Ferrostar Demo.entitlements b/apple/DemoApp/Ferrostar Demo.entitlements new file mode 100644 index 00000000..d74cc083 --- /dev/null +++ b/apple/DemoApp/Ferrostar Demo.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.carplay-maps + + + diff --git a/apple/DemoApp/Ferrostar Demo.xcodeproj/project.pbxproj b/apple/DemoApp/Ferrostar Demo.xcodeproj/project.pbxproj index be51013e..806e4998 100644 --- a/apple/DemoApp/Ferrostar Demo.xcodeproj/project.pbxproj +++ b/apple/DemoApp/Ferrostar Demo.xcodeproj/project.pbxproj @@ -9,8 +9,8 @@ /* Begin PBXBuildFile section */ 1611A5592B2E6E98006B131D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1611A5532B2E6E98006B131D /* Assets.xcassets */; }; 1611A55B2B2E6E98006B131D /* DemoNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1611A5562B2E6E98006B131D /* DemoNavigationView.swift */; }; - 1611A55C2B2E6E98006B131D /* ConfigurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1611A5572B2E6E98006B131D /* ConfigurationView.swift */; }; 1611A55D2B2E6E98006B131D /* DemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1611A5582B2E6E98006B131D /* DemoApp.swift */; }; + 1621C26D2CE2B42700034BA3 /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1621C26C2CE2B42100034BA3 /* CarPlaySceneDelegate.swift */; }; 1663679D2B2F6F85008BFF1F /* APIKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1663679C2B2F6F85008BFF1F /* APIKeys.swift */; }; 1663679F2B2F8FB3008BFF1F /* MockLocationData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1663679E2B2F8FB3008BFF1F /* MockLocationData.swift */; }; 167FD69B2B54B55A00CB4445 /* FerrostarCore in Frameworks */ = {isa = PBXBuildFile; productRef = 167FD69A2B54B55A00CB4445 /* FerrostarCore */; }; @@ -20,22 +20,25 @@ 169A1D582B2E8280006CE59E /* FerrostarMapLibreUI in Frameworks */ = {isa = PBXBuildFile; productRef = 169A1D572B2E8280006CE59E /* FerrostarMapLibreUI */; }; 169B50132B2E46800008EBB7 /* FerrostarCore in Frameworks */ = {isa = PBXBuildFile; productRef = 169B50122B2E46800008EBB7 /* FerrostarCore */; }; 169B50152B2E46800008EBB7 /* FerrostarMapLibreUI in Frameworks */ = {isa = PBXBuildFile; productRef = 169B50142B2E46800008EBB7 /* FerrostarMapLibreUI */; }; - E90D97912B8AF507005E43F8 /* Navigation Delegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E90D97902B8AF507005E43F8 /* Navigation Delegate.swift */; }; + 16C8F1732CEEBE570014DE3D /* AppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16C8F1722CEEBE530014DE3D /* AppEnvironment.swift */; }; + E90D97912B8AF507005E43F8 /* NavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E90D97902B8AF507005E43F8 /* NavigationDelegate.swift */; }; E9505FCA2AD449B30016BF0A /* FerrostarCore in Frameworks */ = {isa = PBXBuildFile; productRef = E9505FC92AD449B30016BF0A /* FerrostarCore */; }; E9505FCC2AD449B30016BF0A /* FerrostarMapLibreUI in Frameworks */ = {isa = PBXBuildFile; productRef = E9505FCB2AD449B30016BF0A /* FerrostarMapLibreUI */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 160FA8132CE2A75E00C36D2F /* Ferrostar Demo.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Ferrostar Demo.entitlements"; sourceTree = ""; }; 1611A5532B2E6E98006B131D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 1611A5552B2E6E98006B131D /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 1611A5562B2E6E98006B131D /* DemoNavigationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoNavigationView.swift; sourceTree = ""; }; - 1611A5572B2E6E98006B131D /* ConfigurationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigurationView.swift; sourceTree = ""; }; 1611A5582B2E6E98006B131D /* DemoApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoApp.swift; sourceTree = ""; }; + 1621C26C2CE2B42100034BA3 /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = ""; }; 1663679C2B2F6F85008BFF1F /* APIKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeys.swift; sourceTree = ""; }; 1663679E2B2F8FB3008BFF1F /* MockLocationData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockLocationData.swift; sourceTree = ""; }; 168ECA792B2E8C42007B11DE /* API-Keys.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "API-Keys.plist"; sourceTree = ""; }; 168ECA7B2B2F6A1E007B11DE /* Ferrostar Demo-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Ferrostar Demo-Info.plist"; sourceTree = ""; }; - E90D97902B8AF507005E43F8 /* Navigation Delegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Navigation Delegate.swift"; sourceTree = ""; }; + 16C8F1722CEEBE530014DE3D /* AppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEnvironment.swift; sourceTree = ""; }; + E90D97902B8AF507005E43F8 /* NavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationDelegate.swift; sourceTree = ""; }; E9505FB72AD449700016BF0A /* Ferrostar Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Ferrostar Demo.app"; sourceTree = BUILT_PRODUCTS_DIR; }; E9DD18E42B18EE7A00CAF29A /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; E9DD18E52B18F4BD00CAF29A /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; @@ -66,9 +69,10 @@ 1611A5532B2E6E98006B131D /* Assets.xcassets */, 1611A5542B2E6E98006B131D /* Preview Content */, 1663679B2B2F6F79008BFF1F /* Helpers */, + 1621C26C2CE2B42100034BA3 /* CarPlaySceneDelegate.swift */, + 16C8F1722CEEBE530014DE3D /* AppEnvironment.swift */, + E90D97902B8AF507005E43F8 /* NavigationDelegate.swift */, 1611A5562B2E6E98006B131D /* DemoNavigationView.swift */, - 1611A5572B2E6E98006B131D /* ConfigurationView.swift */, - E90D97902B8AF507005E43F8 /* Navigation Delegate.swift */, 1611A5582B2E6E98006B131D /* DemoApp.swift */, ); path = Demo; @@ -103,6 +107,7 @@ children = ( 168ECA792B2E8C42007B11DE /* API-Keys.plist */, 168ECA7B2B2F6A1E007B11DE /* Ferrostar Demo-Info.plist */, + 160FA8132CE2A75E00C36D2F /* Ferrostar Demo.entitlements */, 1611A5522B2E6E98006B131D /* Demo */, E9DD18E52B18F4BD00CAF29A /* LICENSE */, E9DD18E42B18EE7A00CAF29A /* README.md */, @@ -203,11 +208,12 @@ buildActionMask = 2147483647; files = ( 1611A55B2B2E6E98006B131D /* DemoNavigationView.swift in Sources */, - 1611A55C2B2E6E98006B131D /* ConfigurationView.swift in Sources */, + 1621C26D2CE2B42700034BA3 /* CarPlaySceneDelegate.swift in Sources */, 1663679D2B2F6F85008BFF1F /* APIKeys.swift in Sources */, 1663679F2B2F8FB3008BFF1F /* MockLocationData.swift in Sources */, + 16C8F1732CEEBE570014DE3D /* AppEnvironment.swift in Sources */, 1611A55D2B2E6E98006B131D /* DemoApp.swift in Sources */, - E90D97912B8AF507005E43F8 /* Navigation Delegate.swift in Sources */, + E90D97912B8AF507005E43F8 /* NavigationDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -338,15 +344,16 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "Ferrostar Demo.entitlements"; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Demo/Preview Content\""; - DEVELOPMENT_TEAM = W4C54KHWZ6; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Ferrostar Demo-Info.plist"; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "This app needs your location to offer turn-by-turn navigation"; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -359,6 +366,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.stadiamaps.ferrostar.Demo; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -370,15 +378,16 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "Ferrostar Demo.entitlements"; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Demo/Preview Content\""; - DEVELOPMENT_TEAM = W4C54KHWZ6; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Ferrostar Demo-Info.plist"; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "This app needs your location to offer turn-by-turn navigation"; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -391,6 +400,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.stadiamaps.ferrostar.Demo; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/apple/Sources/FerrostarCarPlayUI/CarPlayNavigationView.swift b/apple/Sources/FerrostarCarPlayUI/CarPlayNavigationView.swift new file mode 100644 index 00000000..a62d978c --- /dev/null +++ b/apple/Sources/FerrostarCarPlayUI/CarPlayNavigationView.swift @@ -0,0 +1,75 @@ +import FerrostarCore +import FerrostarMapLibreUI +import FerrostarSwiftUI +import MapLibre +import MapLibreSwiftDSL +import MapLibreSwiftUI +import SwiftUI + +public struct CarPlayNavigationView: View, SpeedLimitViewHost, + CurrentRoadNameViewHost +{ + @EnvironmentObject var ferrostarCore: FerrostarCore + + @Environment(\.navigationFormatterCollection) var formatterCollection: any FormatterCollection + + let styleURL: URL + @State var camera: MapViewCamera = .center(.init(latitude: 37.331726, longitude: -122.031790), zoom: 12) + let navigationCamera: MapViewCamera + public var currentRoadNameView: AnyView? + + private let userLayers: [StyleLayerDefinition] + + public var speedLimit: Measurement? + public var speedLimitStyle: SpeedLimitView.SignageStyle? + + public var minimumSafeAreaInsets: EdgeInsets + + /// Create a landscape navigation view. This view is optimized for display on a landscape screen where the + /// instructions are on the leading half of the screen + /// and the user puck and route are on the trailing half of the screen. + /// + /// - Parameters: + /// - styleURL: The map's style url. + /// - camera: The camera binding that represents the current camera on the map. + /// - navigationCamera: The default navigation camera. This sets the initial camera & is also used when the center + /// on user button it tapped. + /// - navigationState: The current ferrostar navigation state provided by the Ferrostar core. + /// - minimumSafeAreaInsets: The minimum padding to apply from safe edges. See `complementSafeAreaInsets`. + /// - onTapExit: An optional behavior to run when the ``TripProgressView`` exit button is tapped. When nil + /// (default) the + /// exit button is hidden. + /// - makeMapContent: Custom maplibre symbols to display on the map view. + public init( + styleURL: URL, + navigationCamera: MapViewCamera = .automotiveNavigation(), + minimumSafeAreaInsets: EdgeInsets = EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16), + @MapViewContentBuilder makeMapContent: () -> [StyleLayerDefinition] = { [] } + ) { + self.styleURL = styleURL + self.minimumSafeAreaInsets = minimumSafeAreaInsets + + userLayers = makeMapContent() + self.navigationCamera = navigationCamera + // TODO: This needs to be instantiated differently to make use of the CarPlay ferrostarCore environment object. +// currentRoadNameView = AnyView(CurrentRoadNameView(currentRoadName: ferrostarCore.state?.currentRoadName)) + } + + public var body: some View { + GeometryReader { geometry in + ZStack { + NavigationMapView( + styleURL: styleURL, + camera: $camera, + navigationState: ferrostarCore.state, + onStyleLoaded: { _ in + camera = navigationCamera + } + ) { + userLayers + } + .navigationMapViewContentInset(.landscape(within: geometry)) + } + } + } +} diff --git a/apple/Sources/FerrostarCarPlayUI/Extensions/NavigationStateCarPlay.swift b/apple/Sources/FerrostarCarPlayUI/Extensions/NavigationStateCarPlay.swift new file mode 100644 index 00000000..4ad5728b --- /dev/null +++ b/apple/Sources/FerrostarCarPlayUI/Extensions/NavigationStateCarPlay.swift @@ -0,0 +1,16 @@ +import CarPlay +import FerrostarCore +import Foundation + +extension NavigationState { + var currentTravelEstimate: CPTravelEstimates? { + guard let metersRemaining = currentProgress?.distanceRemaining, + let secondsRemaining = currentProgress?.durationRemaining else { return nil } + + let distanceRemaining = Measurement(value: metersRemaining, unit: UnitLength.meters) + + return CPTravelEstimates(distanceRemaining: distanceRemaining, + distanceRemainingToDisplay: distanceRemaining, + timeRemaining: secondsRemaining) + } +} diff --git a/apple/Sources/FerrostarCarPlayUI/Extensions/UIHostingControllerInit.swift b/apple/Sources/FerrostarCarPlayUI/Extensions/UIHostingControllerInit.swift new file mode 100644 index 00000000..e2ba7558 --- /dev/null +++ b/apple/Sources/FerrostarCarPlayUI/Extensions/UIHostingControllerInit.swift @@ -0,0 +1,11 @@ +import SwiftUI +import UIKit + +extension UIHostingController where Content: View { + /// Create a SwiftUI view in a UIHosting controller with a closure init. + /// + /// - Parameter content: The convent view builder. + convenience init(@ViewBuilder content: () -> Content) { + self.init(rootView: content()) + } +} diff --git a/apple/Sources/FerrostarCarPlayUI/FerrostarCarPlayManager.swift b/apple/Sources/FerrostarCarPlayUI/FerrostarCarPlayManager.swift new file mode 100644 index 00000000..7a5d8eab --- /dev/null +++ b/apple/Sources/FerrostarCarPlayUI/FerrostarCarPlayManager.swift @@ -0,0 +1,100 @@ +import CarPlay +import FerrostarCore +import FerrostarMapLibreUI +import Foundation +import MapLibreSwiftUI +import SwiftUI +import UIKit + +@MainActor +public class FerrostarCarPlayManager: NSObject, CPInterfaceControllerDelegate, CPSessionConfigurationDelegate { + // MARK: CarPlay Controller & Windows + + private var sessionConfiguration: CPSessionConfiguration! + + private var interfaceController: CPInterfaceController? + private var carWindow: CPWindow? + + private var mapTemplate: CPMapTemplate? + +// private var instrumentClusterWindow: UIWindow? +// var currentTravelEstimates: CPTravelEstimates? +// var navigationSession: CPNavigationSession? +// var displayLink: CADisplayLink? +// var activeManeuver: CPManeuver? +// var activeEstimates: CPTravelEstimates? +// var lastCompletedManeuverFrame: CGRect? + + private let ferrostarCore: FerrostarCore + private let styleURL: URL + + private var viewController: UIHostingController! + + public init( + ferrostarCore: FerrostarCore, + styleURL: URL + ) { + self.ferrostarCore = ferrostarCore + self.styleURL = styleURL + + super.init() + +// sessionConfiguration = CPSessionConfiguration(delegate: self) + } + + public func templateApplicationScene( + _: CPTemplateApplicationScene, + didConnect interfaceController: CPInterfaceController, + to window: CPWindow + ) { + // Retain references to the interface controller and window for + // the entire duration of the CarPlay session. + self.interfaceController = interfaceController + carWindow = window + + // Assign the window's root view controller to the view controller + // that draws your map content. + window.rootViewController = UIHostingController { + CarPlayNavigationView(styleURL: styleURL) + .environmentObject(ferrostarCore) + } + + // Create a map template and set it as the root. + let mapTemplate = makeMapTemplate() + interfaceController.setRootTemplate(mapTemplate, animated: true, + completion: nil) + } + + func makeMapTemplate() -> CPMapTemplate { + let mapTemplate = CPMapTemplate() + return mapTemplate + } +} + +@MainActor +extension FerrostarCarPlayManager: CPTemplateApplicationDashboardSceneDelegate { + public func templateApplicationDashboardScene( + _: CPTemplateApplicationDashboardScene, + didConnect _: CPDashboardController, + to _: UIWindow + ) {} + + public func templateApplicationDashboardScene( + _: CPTemplateApplicationDashboardScene, + didDisconnect _: CPDashboardController, + from _: UIWindow + ) {} +} + +@MainActor +extension FerrostarCarPlayManager: CPTemplateApplicationInstrumentClusterSceneDelegate { + public func templateApplicationInstrumentClusterScene( + _: CPTemplateApplicationInstrumentClusterScene, + didConnect _: CPInstrumentClusterController + ) {} + + public func templateApplicationInstrumentClusterScene( + _: CPTemplateApplicationInstrumentClusterScene, + didDisconnectInstrumentClusterController _: CPInstrumentClusterController + ) {} +} diff --git a/guide/src/SUMMARY.md b/guide/src/SUMMARY.md index 978ade6c..8b562f19 100644 --- a/guide/src/SUMMARY.md +++ b/guide/src/SUMMARY.md @@ -21,6 +21,7 @@ - [Jetpack Compose](./jetpack-compose-customization.md) - [Android Foreground Services](./android-foreground-service.md) - [Web](./web-customization.md) + - [iOS CarPlay](./ios-carplay.md) # Contributor Guide diff --git a/guide/src/ios-carplay.md b/guide/src/ios-carplay.md new file mode 100644 index 00000000..f1dc0f52 --- /dev/null +++ b/guide/src/ios-carplay.md @@ -0,0 +1,5 @@ +# CarPlay on iOS + +CarPlay is currently a work in progress that you can see in the demo app. + +- [Apple - Using the CarPlay Simulator](https://developer.apple.com/documentation/carplay/using-the-carplay-simulator) \ No newline at end of file