diff --git a/example/android/app/src/main/kotlin/com/google/maps/flutter/navigation_example/LaneConverter.kt b/example/android/app/src/main/kotlin/com/google/maps/flutter/navigation_example/LaneConverter.kt new file mode 100644 index 00000000..c6589207 --- /dev/null +++ b/example/android/app/src/main/kotlin/com/google/maps/flutter/navigation_example/LaneConverter.kt @@ -0,0 +1,91 @@ +/* +* Copyright 2024 Google LLC +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.google.maps.flutter.navigation_example + +import androidx.car.app.navigation.model.Lane +import androidx.car.app.navigation.model.LaneDirection +import com.google.android.libraries.mapsplatform.turnbyturn.model.LaneDirection.LaneShape + +/** Converter that converts between turn-by-turn Lanes and Android Auto Lanes. */ +object LaneConverter { + + /** + * Converts a list of turn-by-turn lanes to Android Auto lanes. + * + * @param lanes List of lanes from StepInfo + * @return List of Android Auto Lane objects + */ + fun convertToAndroidAutoLanes( + lanes: List? + ): List? { + if (lanes.isNullOrEmpty()) { + return null + } + + return lanes.mapNotNull { lane -> + try { + val laneDirections = lane.laneDirections() + if (laneDirections.isNullOrEmpty()) { + null + } else { + val builder = Lane.Builder() + for (laneDirection in laneDirections) { + val carLaneDirection = convertLaneDirection(laneDirection) + builder.addDirection(carLaneDirection) + } + builder.build() + } + } catch (e: Exception) { + null + } + } + } + + /** + * Converts a single turn-by-turn lane direction to an Android Auto LaneDirection. + * + * @param laneDirection Lane direction from turn-by-turn + * @return Android Auto LaneDirection + */ + private fun convertLaneDirection( + laneDirection: com.google.android.libraries.mapsplatform.turnbyturn.model.LaneDirection + ): LaneDirection { + val shape = convertLaneShape(laneDirection.laneShape()) + return LaneDirection.create(shape, laneDirection.isRecommended) + } + + /** + * Converts a turn-by-turn lane shape to an Android Auto lane direction shape. + * + * @param laneShape Lane shape from turn-by-turn (LaneShape constant) + * @return Android Auto LaneDirection shape constant + */ + private fun convertLaneShape(laneShape: Int): Int { + return when (laneShape) { + LaneShape.STRAIGHT -> LaneDirection.SHAPE_STRAIGHT + LaneShape.SLIGHT_LEFT -> LaneDirection.SHAPE_SLIGHT_LEFT + LaneShape.SLIGHT_RIGHT -> LaneDirection.SHAPE_SLIGHT_RIGHT + LaneShape.NORMAL_LEFT -> LaneDirection.SHAPE_NORMAL_LEFT + LaneShape.NORMAL_RIGHT -> LaneDirection.SHAPE_NORMAL_RIGHT + LaneShape.SHARP_LEFT -> LaneDirection.SHAPE_SHARP_LEFT + LaneShape.SHARP_RIGHT -> LaneDirection.SHAPE_SHARP_RIGHT + LaneShape.U_TURN_LEFT -> LaneDirection.SHAPE_U_TURN_LEFT + LaneShape.U_TURN_RIGHT -> LaneDirection.SHAPE_U_TURN_RIGHT + else -> LaneDirection.SHAPE_UNKNOWN + } + } +} diff --git a/example/android/app/src/main/kotlin/com/google/maps/flutter/navigation_example/SampleAndroidAutoScreen.kt b/example/android/app/src/main/kotlin/com/google/maps/flutter/navigation_example/SampleAndroidAutoScreen.kt index 447a585c..64390e14 100644 --- a/example/android/app/src/main/kotlin/com/google/maps/flutter/navigation_example/SampleAndroidAutoScreen.kt +++ b/example/android/app/src/main/kotlin/com/google/maps/flutter/navigation_example/SampleAndroidAutoScreen.kt @@ -1,23 +1,10 @@ -/* - * Copyright 2024 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - package com.google.maps.flutter.navigation_example import android.annotation.SuppressLint +import android.app.Application +import android.util.Log import androidx.car.app.CarContext +import androidx.car.app.SurfaceContainer import androidx.car.app.model.Action import androidx.car.app.model.ActionStrip import androidx.car.app.model.CarIcon @@ -26,55 +13,147 @@ import androidx.car.app.model.Pane import androidx.car.app.model.PaneTemplate import androidx.car.app.model.Row import androidx.car.app.model.Template +import androidx.car.app.model.DateTimeWithZone import androidx.car.app.navigation.model.Maneuver import androidx.car.app.navigation.model.NavigationTemplate import androidx.car.app.navigation.model.RoutingInfo import androidx.car.app.navigation.model.Step +import androidx.car.app.navigation.model.TravelEstimate import androidx.core.graphics.drawable.IconCompat import com.google.android.gms.maps.GoogleMap import com.google.android.libraries.mapsplatform.turnbyturn.model.NavInfo import com.google.android.libraries.mapsplatform.turnbyturn.model.StepInfo +import com.google.android.libraries.navigation.Navigator +import com.google.android.libraries.navigation.NavigationApi import com.google.maps.flutter.navigation.AndroidAutoBaseScreen import com.google.maps.flutter.navigation.GoogleMapsNavigationNavUpdatesService +import java.util.TimeZone +class SampleAndroidAutoScreen(carContext: CarContext) : AndroidAutoBaseScreen(carContext) { -class SampleAndroidAutoScreen(carContext: CarContext): AndroidAutoBaseScreen(carContext) { + companion object { + private const val TAG = "SampleAndroidAutoScreen" + // Percentage of width for navigation panel (approximately 40%) + private const val NAVIGATION_PANEL_WIDTH_RATIO = 0.40f + + // Maximum value for top padding (based on car_app_bar_height from framework) + private const val TOP_PADDING_NO_GUIDANCE_MAX = 80 + } + + /** + * Formats distance with smart rounding similar to iOS CarPlay: + * - >= 1km: show in km with 1 decimal precision + * - >= 100m: round to nearest 50m + * - < 100m: round to nearest 10m + */ + private fun formatDistance(distanceMeters: Double): Distance { + return when { + distanceMeters >= 1000 -> { + // >= 1km: convert to km with 1 decimal precision + val km = distanceMeters / 1000.0 + val roundedKm = Math.round(km * 10.0) / 10.0 + Distance.create(roundedKm, Distance.UNIT_KILOMETERS) + } + distanceMeters >= 100 -> { + // >= 100m: round to nearest 50m + val roundedMeters = Math.round(distanceMeters / 50.0) * 50.0 + Distance.create(roundedMeters, Distance.UNIT_METERS) + } + else -> { + // < 100m: round to nearest 10m + val roundedMeters = Math.round(distanceMeters / 10.0) * 10.0 + Distance.create(roundedMeters, Distance.UNIT_METERS) + } + } + } + private var mTravelEstimate: TravelEstimate? = null private var mNavInfo: RoutingInfo? = null + private var mNavigator: Navigator? = null + private var mIsGuidanceRunning: Boolean = false + private var mRouteChangedListener: Navigator.RouteChangedListener? = null + + // Dimensions calculated from screen + private var mScreenWidth: Int = 0 + private var mScreenHeight: Int = 0 + private var mNavigationPanelWidth: Int = 0 + private var mTopPaddingNoGuidance: Int = 0 + init { - // Connect to the Turn-by-Turn Navigation service to receive navigation data. GoogleMapsNavigationNavUpdatesService.navInfoLiveData.observe(this) { navInfo: NavInfo? -> - this.buildNavInfo( - navInfo - ) + try { + this.buildNavInfo(navInfo) + } catch (e: Exception) { + Log.e(TAG, "πŸ”· [AndroidAuto] Error in buildNavInfo: ${e.message}") + mNavInfo = null + mTravelEstimate = null + invalidate() + } } } private fun buildNavInfo(navInfo: NavInfo?) { - if (navInfo == null || navInfo.currentStep == null) { + Log.d(TAG, "πŸ”· [AndroidAuto] buildNavInfo() called") + + checkAndUpdateGuidanceState() + + if (navInfo == null) { + Log.d(TAG, "πŸ”· [AndroidAuto] buildNavInfo() - navInfo null, clearing overlays") + mNavInfo = null + mTravelEstimate = null + invalidate() + return + } + + val currentStepInfo = navInfo.currentStep + if (currentStepInfo == null) { + Log.d(TAG, "πŸ”· [AndroidAuto] buildNavInfo() - currentStep null, clearing overlays") + mNavInfo = null + mTravelEstimate = null + invalidate() return } - /** - * Converts data received from the Navigation data feed into Android-Auto compatible data - * structures. - */ - val currentStep: Step = buildStepFromStepInfo(navInfo.currentStep!!) - val distanceToStep = - Distance.create( - java.lang.Double.max( - navInfo.distanceToCurrentStepMeters?.toDouble() ?: 0.0, - 0.0 - ), Distance.UNIT_METERS + try { + val currentStep: Step = buildStepFromStepInfo(currentStepInfo) + val distanceToStepMeters = java.lang.Double.max( + navInfo.distanceToCurrentStepMeters?.toDouble() ?: 0.0, + 0.0 ) + val distanceToStep = formatDistance(distanceToStepMeters) + + mNavInfo = RoutingInfo.Builder().setCurrentStep(currentStep, distanceToStep).build() + } catch (e: Exception) { + Log.e(TAG, "πŸ”· [AndroidAuto] buildNavInfo() - error building step: ${e.message}") + mNavInfo = null + } + + try { + val timeToDestinationSeconds = navInfo.timeToNextDestinationSeconds + val distanceToDestinationMeters = navInfo.distanceToNextDestinationMeters - mNavInfo = RoutingInfo.Builder().setCurrentStep(currentStep, distanceToStep).build() + if (timeToDestinationSeconds == null || distanceToDestinationMeters == null || + (timeToDestinationSeconds <= 0 && distanceToDestinationMeters <= 0)) { + mTravelEstimate = null + } else { + val arrivalTimeMillis = System.currentTimeMillis() + (timeToDestinationSeconds * 1000) + val arrivalTime = DateTimeWithZone.create(arrivalTimeMillis, TimeZone.getDefault()) + val remainingDistance = formatDistance(distanceToDestinationMeters.toDouble()) + + mTravelEstimate = TravelEstimate.Builder(remainingDistance, arrivalTime) + .setRemainingTimeSeconds(timeToDestinationSeconds.toLong()) + .build() + } + } catch (e: Exception) { + Log.e(TAG, "πŸ”· [AndroidAuto] buildNavInfo() - error building travel estimate: ${e.message}") + mTravelEstimate = null + } - // Invalidate the current template which leads to another onGetTemplate call. invalidate() } private fun buildStepFromStepInfo(stepInfo: StepInfo): Step { + try { val maneuver: Int = ManeuverConverter.getAndroidAutoManeuverType(stepInfo.maneuver) val maneuverBuilder = Maneuver.Builder(maneuver) if (stepInfo.maneuverBitmap != null) { @@ -87,27 +166,100 @@ class SampleAndroidAutoScreen(carContext: CarContext): AndroidAutoBaseScreen(car .setRoad(stepInfo.fullRoadName ?: "") .setCue(stepInfo.fullInstructionText ?: "") .setManeuver(maneuverBuilder.build()) + + // Add lane guidance if available (requires both lanes data AND lanes image) + if (stepInfo.lanes != null && stepInfo.lanes!!.isNotEmpty() && stepInfo.lanesBitmap != null) { + val androidAutoLanes = LaneConverter.convertToAndroidAutoLanes(stepInfo.lanes) + if (androidAutoLanes != null && androidAutoLanes.isNotEmpty()) { + // Add lanes + for (lane in androidAutoLanes) { + stepBuilder.addLane(lane) + } + + // Add lanes image (REQUIRED by Android Auto when lanes are present) + val lanesIcon = IconCompat.createWithBitmap(stepInfo.lanesBitmap!!) + val lanesCarIcon = CarIcon.Builder(lanesIcon).build() + stepBuilder.setLanesImage(lanesCarIcon) + + Log.d(TAG, "πŸ”· [AndroidAuto] Added ${androidAutoLanes.size} lanes with image to step") + } + } + return stepBuilder.build() + } catch (e: Exception) { + Log.e(TAG, "πŸ”· [AndroidAuto] buildStepFromStepInfo() - error: ${e.message}") + val defaultManeuver = Maneuver.Builder(Maneuver.TYPE_STRAIGHT).build() + return Step.Builder() + .setRoad("") + .setCue("") + .setManeuver(defaultManeuver) + .build() + } + } + + override fun onSurfaceAvailable(surfaceContainer: SurfaceContainer) { + super.onSurfaceAvailable(surfaceContainer) + + mScreenWidth = surfaceContainer.width + mScreenHeight = surfaceContainer.height + val screenDpi = surfaceContainer.dpi + + val density = screenDpi / 160f + + val navigationPanelWidthFromRatio = (mScreenWidth * NAVIGATION_PANEL_WIDTH_RATIO).toInt() + + mNavigationPanelWidth = navigationPanelWidthFromRatio + mTopPaddingNoGuidance = TOP_PADDING_NO_GUIDANCE_MAX + + Log.d(TAG, "🟠 [AndroidAuto] onSurfaceAvailable() - density: ${density}") + Log.d(TAG, "🟠 [AndroidAuto] onSurfaceAvailable() - screen: ${mScreenWidth}x${mScreenHeight}px") + Log.d(TAG, "🟠 [AndroidAuto] onSurfaceAvailable() - navPanel: ${mNavigationPanelWidth}px, topPadding: ${mTopPaddingNoGuidance}px") } override fun onNavigationReady(ready: Boolean) { super.onNavigationReady(ready) - // Invalidate template layout because of conditional rendering in the - // onGetTemplate method. + + if (ready && mNavigator == null) { + Log.d(TAG, "βœ… [AndroidAuto] onNavigationReady() - getting Navigator") + NavigationApi.getNavigator( + carContext.applicationContext as Application, + object : NavigationApi.NavigatorListener { + override fun onNavigatorReady(navigator: Navigator) { + Log.d(TAG, "βœ… [AndroidAuto] onNavigatorReady() - Navigator ready") + mNavigator = navigator + + // Add listener to detect route changes + mRouteChangedListener = Navigator.RouteChangedListener { + Log.d(TAG, "πŸ—ΊοΈ [AndroidAuto] onRouteChanged() - route changed, invalidating template") + invalidate() + } + navigator.addRouteChangedListener(mRouteChangedListener) + + checkAndUpdateGuidanceState() + invalidate() + } + + override fun onError(errorCode: Int) { + Log.e(TAG, "βœ… [AndroidAuto] onNavigatorReady() - error: $errorCode") + } + } + ) + } + + checkAndUpdateGuidanceState() invalidate() } override fun onGetTemplate(): Template { - if (!mIsNavigationReady) { + // Single waiting screen for both cases: navigation not ready OR no route available + if (!mIsNavigationReady || mNavigator?.currentRouteSegment == null) { + Log.d(TAG, "πŸ”· [AndroidAuto] onGetTemplate() - waiting (navReady: $mIsNavigationReady, hasRoute: ${mNavigator?.currentRouteSegment != null})") return PaneTemplate.Builder( Pane.Builder() .addRow( Row.Builder() - .setTitle("Nav SampleApp") - .addText( - "Initialize navigation to see navigation view on the Android Auto" - + " screen" - ) + .setTitle("Waiting") + .addText("Waiting for navigation session...") .build() ) .build() @@ -118,34 +270,203 @@ class SampleAndroidAutoScreen(carContext: CarContext): AndroidAutoBaseScreen(car // "android.permission.ACCESS_COARSE_LOCATION" or "android.permission.ACCESS_FINE_LOCATION", as // these permissions are already handled elsewhere. @SuppressLint("MissingPermission") - val navigationTemplateBuilder = - NavigationTemplate.Builder() - .setActionStrip( - ActionStrip.Builder() - .addAction( + val actionStripBuilder = ActionStrip.Builder() + .addAction( Action.Builder() - .setTitle("Re-center") + .setTitle(getStartStopButtonTitle()) .setOnClickListener { - if (mGoogleMap == null) return@setOnClickListener - mGoogleMap!!.followMyLocation(GoogleMap.CameraPerspective.TILTED) + mNavigator?.let { navigator -> + try { + if (navigator.isGuidanceRunning) { + Log.d(TAG, "πŸ”΅ [AndroidAuto] Stop button pressed") + sendCustomNavigationAutoEvent("AutoEventStop", mapOf("timestamp" to System.currentTimeMillis().toString())) + navigator.stopGuidance() + } else { + Log.d(TAG, "πŸ”΅ [AndroidAuto] Start button pressed") + sendCustomNavigationAutoEvent("AutoEventStart", mapOf("timestamp" to System.currentTimeMillis().toString())) + navigator.startGuidance() + } + invalidate() + } catch (e: Exception) { + Log.e(TAG, "πŸ”΅ [AndroidAuto] Error toggling guidance: ${e.message}") + } + } + } - .build()) - .addAction( - Action.Builder() - .setTitle("Custom event") - .setOnClickListener { - sendCustomNavigationAutoEvent("CustomAndroidAutoEvent", mapOf("sampleDataKey" to "sampleDataContent")) - } - .build()) - .build()) + .build()) + + // Add the "show itinerary" button only if guidance is not active + if (mNavigator?.isGuidanceRunning == false) { + actionStripBuilder.addAction( + Action.Builder() + .setTitle("Route") + .setOnClickListener { + Log.d(TAG, "πŸ”΅ [AndroidAuto] Itinerary button pressed") + sendCustomNavigationAutoEvent("show_itinerary_button_pressed", mapOf("timestamp" to System.currentTimeMillis().toString())) + showRouteOverview() + } + .build()) + } + + actionStripBuilder.addAction( + Action.Builder() + .setTitle("Recenter") + .setOnClickListener { + Log.d(TAG, "πŸ”΅ [AndroidAuto] Recenter button pressed") + mGoogleMap?.followMyLocation(GoogleMap.CameraPerspective.TILTED) + sendCustomNavigationAutoEvent("recenter_button_pressed", mapOf("timestamp" to System.currentTimeMillis().toString())) + } + .build()) + + val navigationTemplateBuilder = + NavigationTemplate.Builder() + .setActionStrip(actionStripBuilder.build()) .setMapActionStrip(ActionStrip.Builder().addAction(Action.PAN).build()) - - // Show turn-by-turn navigation information if available. if (mNavInfo != null) { navigationTemplateBuilder.setNavigationInfo(mNavInfo!!) } + mTravelEstimate?.let { travelEstimate -> + navigationTemplateBuilder.setDestinationTravelEstimate(travelEstimate) + } + return navigationTemplateBuilder.build() } -} \ No newline at end of file + + private fun getStartStopButtonTitle(): String { + return if (mNavigator == null) { + "" + } else { + try { + if (mNavigator?.isGuidanceRunning == true) "Stop" else "Start" + } catch (e: Exception) { + Log.e(TAG, "πŸ”΅ [AndroidAuto] getStartStopButtonTitle() - error: ${e.message}") + "Start" + } + } + } + + private fun checkAndUpdateGuidanceState() { + try { + val isGuidanceRunning = mNavigator?.isGuidanceRunning ?: false + + if (isGuidanceRunning != mIsGuidanceRunning) { + Log.d(TAG, "⏱️ [AndroidAuto] Guidance state changed to: $isGuidanceRunning") + mIsGuidanceRunning = isGuidanceRunning + updateMapPaddingForGuidance(isGuidanceRunning) + } + + } catch (e: Exception) { + Log.e(TAG, "⏱️ [AndroidAuto] checkAndUpdateGuidanceState() - error: ${e.message}") + } + } + + private fun updateMapPaddingForGuidance(isGuidanceActive: Boolean) { + try { + val leftPadding: Int + val topPadding: Int + + if (isGuidanceActive) { + leftPadding = mNavigationPanelWidth + topPadding = 0 + } else { + leftPadding = 0 + topPadding = mTopPaddingNoGuidance + } + + mGoogleMap?.setPadding(leftPadding, topPadding, 0, 0) + + Log.d(TAG, "πŸ—ΊοΈ [AndroidAuto] Map padding updated - left: ${leftPadding}px, top: ${topPadding}px") + } catch (e: Exception) { + Log.e(TAG, "πŸ—ΊοΈ [AndroidAuto] updateMapPaddingForGuidance() - error: ${e.message}") + } + } + + @SuppressLint("MissingPermission") + private fun showRouteOverview() { + try { + Log.d(TAG, "πŸ—ΊοΈ [AndroidAuto] showRouteOverview() called") + + val googleMap = mGoogleMap + if (googleMap == null) { + Log.w(TAG, "πŸ—ΊοΈ [AndroidAuto] showRouteOverview() - GoogleMap null") + return + } + + // Force padding update before calculating bounds + val isGuidanceRunning = mNavigator?.isGuidanceRunning ?: false + updateMapPaddingForGuidance(isGuidanceRunning) + + val navigator = mNavigator + if (navigator == null) { + Log.w(TAG, "πŸ—ΊοΈ [AndroidAuto] showRouteOverview() - Navigator null") + return + } + + val currentRouteSegment = navigator.currentRouteSegment + if (currentRouteSegment == null) { + Log.w(TAG, "πŸ—ΊοΈ [AndroidAuto] showRouteOverview() - no route segment") + return + } + + val pathBuilder = com.google.android.gms.maps.model.LatLngBounds.Builder() + var hasPoints = false + + googleMap.myLocation?.let { location -> + pathBuilder.include(com.google.android.gms.maps.model.LatLng(location.latitude, location.longitude)) + hasPoints = true + } + + val routeSegments = navigator.routeSegments + if (routeSegments != null && routeSegments.isNotEmpty()) { + for (segment in routeSegments) { + val latLngs = segment.latLngs + if (latLngs != null && latLngs.isNotEmpty()) { + for (latLng in latLngs) { + pathBuilder.include(latLng) + hasPoints = true + } + } + + segment.destinationWaypoint?.position?.let { position -> + pathBuilder.include(com.google.android.gms.maps.model.LatLng(position.latitude, position.longitude)) + hasPoints = true + } + } + } + + if (!hasPoints) { + Log.w(TAG, "πŸ—ΊοΈ [AndroidAuto] showRouteOverview() - no points to build bounds") + return + } + + try { + val originalBounds = pathBuilder.build() + val padding = 20 + val cameraUpdate = com.google.android.gms.maps.CameraUpdateFactory.newLatLngBounds( + originalBounds, + padding + ) + googleMap.animateCamera(cameraUpdate, 500, null) + Log.d(TAG, "πŸ—ΊοΈ [AndroidAuto] showRouteOverview() - success") + } catch (e: IllegalStateException) { + Log.w(TAG, "πŸ—ΊοΈ [AndroidAuto] showRouteOverview() - could not build bounds: ${e.message}") + } + } catch (e: Exception) { + Log.e(TAG, "πŸ—ΊοΈ [AndroidAuto] showRouteOverview() - error: ${e.message}") + } + } + + override fun onSurfaceDestroyed(surfaceContainer: androidx.car.app.SurfaceContainer) { + super.onSurfaceDestroyed(surfaceContainer) + + // Clean up route changed listener + mRouteChangedListener?.let { listener -> + mNavigator?.removeRouteChangedListener(listener) + Log.d(TAG, "πŸ”΄ [AndroidAuto] onSurfaceDestroyed() - RouteChangedListener removed") + } + mRouteChangedListener = null + mNavigator = null + } +} diff --git a/example/ios/Podfile b/example/ios/Podfile index f47daa68..f4c24a13 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -38,6 +38,8 @@ target 'Runner' do target 'RunnerUITests' do inherit! :complete end + + pod 'SnapKit', '~> 5.7.1' end target 'RunnerCarPlay' do @@ -45,6 +47,8 @@ target 'RunnerCarPlay' do use_modular_headers! flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + + pod 'SnapKit', '~> 5.7.1' end post_install do |installer| diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index babaf9c3..e8d04f4a 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -22,6 +22,15 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 506F574D2AD8012C004AC70F /* RunnerUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = 506F574C2AD8012C004AC70F /* RunnerUITests.m */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 9178D3B72F36473600EDB34F /* CarPlayManeuverIconConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9178D3AF2F36473600EDB34F /* CarPlayManeuverIconConverter.swift */; }; + 9178D3B82F36473600EDB34F /* CarPlayNavigationInfoLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9178D3B02F36473600EDB34F /* CarPlayNavigationInfoLayout.swift */; }; + 9178D3B92F36473600EDB34F /* CarPlayButtonFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9178D3AE2F36473600EDB34F /* CarPlayButtonFactory.swift */; }; + 9178D3BA2F36473600EDB34F /* CarPlayNavigationListenerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9178D3B12F36473600EDB34F /* CarPlayNavigationListenerCoordinator.swift */; }; + 9178D3BB2F36473600EDB34F /* CarPlayNavigationStateMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9178D3B22F36473600EDB34F /* CarPlayNavigationStateMonitor.swift */; }; + 9178D3BC2F36473600EDB34F /* CarPlayOverlayLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9178D3B32F36473600EDB34F /* CarPlayOverlayLayout.swift */; }; + 9178D3BD2F36473600EDB34F /* CarPlayTemplateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9178D3B52F36473600EDB34F /* CarPlayTemplateManager.swift */; }; + 9178D3BE2F36473600EDB34F /* CarPlayOverlayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9178D3B42F36473600EDB34F /* CarPlayOverlayManager.swift */; }; + 9178D3BF2F36473600EDB34F /* CarPlayTravelEstimatedLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9178D3B62F36473600EDB34F /* CarPlayTravelEstimatedLayout.swift */; }; 9622089A4166ECC03B433965 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 054752A3C68A7FF5D59CC247 /* Pods_RunnerTests.framework */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -98,6 +107,15 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 8A1462748CBFF934FB9BF87A /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 9178D3AE2F36473600EDB34F /* CarPlayButtonFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayButtonFactory.swift; sourceTree = ""; }; + 9178D3AF2F36473600EDB34F /* CarPlayManeuverIconConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayManeuverIconConverter.swift; sourceTree = ""; }; + 9178D3B02F36473600EDB34F /* CarPlayNavigationInfoLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayNavigationInfoLayout.swift; sourceTree = ""; }; + 9178D3B12F36473600EDB34F /* CarPlayNavigationListenerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayNavigationListenerCoordinator.swift; sourceTree = ""; }; + 9178D3B22F36473600EDB34F /* CarPlayNavigationStateMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayNavigationStateMonitor.swift; sourceTree = ""; }; + 9178D3B32F36473600EDB34F /* CarPlayOverlayLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayOverlayLayout.swift; sourceTree = ""; }; + 9178D3B42F36473600EDB34F /* CarPlayOverlayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayOverlayManager.swift; sourceTree = ""; }; + 9178D3B52F36473600EDB34F /* CarPlayTemplateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayTemplateManager.swift; sourceTree = ""; }; + 9178D3B62F36473600EDB34F /* CarPlayTravelEstimatedLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayTravelEstimatedLayout.swift; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -211,6 +229,15 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + 9178D3AE2F36473600EDB34F /* CarPlayButtonFactory.swift */, + 9178D3AF2F36473600EDB34F /* CarPlayManeuverIconConverter.swift */, + 9178D3B02F36473600EDB34F /* CarPlayNavigationInfoLayout.swift */, + 9178D3B12F36473600EDB34F /* CarPlayNavigationListenerCoordinator.swift */, + 9178D3B22F36473600EDB34F /* CarPlayNavigationStateMonitor.swift */, + 9178D3B32F36473600EDB34F /* CarPlayOverlayLayout.swift */, + 9178D3B42F36473600EDB34F /* CarPlayOverlayManager.swift */, + 9178D3B52F36473600EDB34F /* CarPlayTemplateManager.swift */, + 9178D3B62F36473600EDB34F /* CarPlayTravelEstimatedLayout.swift */, 2A8C4ADB2CC653B800168311 /* RunnerCarPlay.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, @@ -742,6 +769,15 @@ 2A8C4ADD2CC656D700168311 /* PhoneSceneDelegate.swift in Sources */, 2A8C4ADF2CC656E800168311 /* CarSceneDelegate.swift in Sources */, 2A8C4AD32CC64AE500168311 /* main.swift in Sources */, + 9178D3B72F36473600EDB34F /* CarPlayManeuverIconConverter.swift in Sources */, + 9178D3B82F36473600EDB34F /* CarPlayNavigationInfoLayout.swift in Sources */, + 9178D3B92F36473600EDB34F /* CarPlayButtonFactory.swift in Sources */, + 9178D3BA2F36473600EDB34F /* CarPlayNavigationListenerCoordinator.swift in Sources */, + 9178D3BB2F36473600EDB34F /* CarPlayNavigationStateMonitor.swift in Sources */, + 9178D3BC2F36473600EDB34F /* CarPlayOverlayLayout.swift in Sources */, + 9178D3BD2F36473600EDB34F /* CarPlayTemplateManager.swift in Sources */, + 9178D3BE2F36473600EDB34F /* CarPlayOverlayManager.swift in Sources */, + 9178D3BF2F36473600EDB34F /* CarPlayTravelEstimatedLayout.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/example/ios/Runner/CarPlayButtonFactory.swift b/example/ios/Runner/CarPlayButtonFactory.swift new file mode 100644 index 00000000..469f6ce3 --- /dev/null +++ b/example/ios/Runner/CarPlayButtonFactory.swift @@ -0,0 +1,111 @@ +import CarPlay +import GoogleNavigation +import UIKit +import google_navigation_flutter + +protocol CarPlayButtonFactoryDelegate: AnyObject { + func buttonFactoryDidRequestStartStop(isActive: Bool) + func buttonFactoryDidRequestViewToggle(isShowingOverview: Bool) +} + +class CarPlayButtonFactory { + // MARK: - Properties + + weak var delegate: CarPlayButtonFactoryDelegate? + private var getNavView: () -> GoogleMapsNavigationView? + private(set) var isShowingRouteOverview: Bool = false + + // MARK: - Initialization + + init(getNavView: @escaping () -> GoogleMapsNavigationView?) { + self.getNavView = getNavView + } + + // MARK: - Button Creation + + func createStartStopButton(isGuidanceActive: Bool) -> CPBarButton { + let button = CPBarButton(title: isGuidanceActive ? "Stop" : "Start") { [weak self] _ in + self?.handleStartStopTapped(currentState: isGuidanceActive) + } + return button + } + + func createViewToggleButton() -> CPBarButton { + let button = CPBarButton( + title: isShowingRouteOverview ? "Re-center" : "Show itinerary" + ) { [weak self] _ in + self?.handleViewToggleTapped() + } + return button + } + + func updateTemplateButtons(_ template: CPMapTemplate, isGuidanceActive: Bool) { + NSLog("πŸ”· [ButtonFactory] updateTemplateButtons() - isGuidanceActive: \(isGuidanceActive)") + + let startOrQuitButton = createStartStopButton(isGuidanceActive: isGuidanceActive) + + if isGuidanceActive { + template.leadingNavigationBarButtons = [startOrQuitButton] + } else { + let viewToggleButton = createViewToggleButton() + template.leadingNavigationBarButtons = [startOrQuitButton, viewToggleButton] + } + } + + // MARK: - Button Actions + + private func handleStartStopTapped(currentState: Bool) { + NSLog("πŸ”· [ButtonFactory] handleStartStopTapped() - currentState: \(currentState)") + + guard let navView = getNavView(), + let mapView = navView.view() as? GMSMapView, + let navigator = mapView.navigator + else { + NSLog("πŸ”· [ButtonFactory] handleStartStopTapped() - navigator not available") + return + } + + navigator.isGuidanceActive = !currentState + delegate?.buttonFactoryDidRequestStartStop(isActive: !currentState) + } + + private func handleViewToggleTapped() { + NSLog( + "πŸ”· [ButtonFactory] handleViewToggleTapped() - isShowingOverview: \(isShowingRouteOverview)" + ) + + guard let navView = getNavView(), + let mapView = navView.view() as? GMSMapView + else { + NSLog("πŸ”· [ButtonFactory] handleViewToggleTapped() - mapView not available") + return + } + + if isShowingRouteOverview { + // Switch to re-center + NSLog("πŸ”· [ButtonFactory] handleViewToggleTapped() - switching to re-center") + navView.followMyLocation( + perspective: GMSNavigationCameraPerspective.tilted, + zoomLevel: nil + ) + isShowingRouteOverview = false + } else { + // Switch to overview + NSLog("πŸ”· [ButtonFactory] handleViewToggleTapped() - switching to route overview") + mapView.cameraMode = .overview + isShowingRouteOverview = true + } + + delegate?.buttonFactoryDidRequestViewToggle(isShowingOverview: isShowingRouteOverview) + } + + func resetViewState() { + isShowingRouteOverview = false + } + + // MARK: - Cleanup + + func cleanup() { + isShowingRouteOverview = false + } +} diff --git a/example/ios/Runner/CarPlayNavigationListenerCoordinator.swift b/example/ios/Runner/CarPlayNavigationListenerCoordinator.swift new file mode 100644 index 00000000..068ef138 --- /dev/null +++ b/example/ios/Runner/CarPlayNavigationListenerCoordinator.swift @@ -0,0 +1,148 @@ +import CoreLocation +import Foundation +import GoogleNavigation +import google_navigation_flutter + +protocol CarPlayNavigationListenerCoordinatorDelegate: AnyObject { + func navigatorDidUpdateRemainingTime(_ time: TimeInterval) + func navigatorDidUpdateRemainingDistance(_ distance: CLLocationDistance) + func navigatorDidUpdateNavInfo( + _ navInfo: GMSNavigationNavInfo, nextManeuver: GMSNavigationManeuver?) + func navigatorDidChangeRoute() +} + +class CarPlayNavigationListenerCoordinator: NSObject, GMSNavigatorListener { + // MARK: - Properties + + weak var delegate: CarPlayNavigationListenerCoordinatorDelegate? + private var getNavView: () -> GoogleMapsNavigationView? + private var isListenerAttached = false + private(set) var cachedRemainingTime: TimeInterval? + private(set) var cachedRemainingDistance: CLLocationDistance? + private(set) var cachedNavInfo: GMSNavigationNavInfo? + + // MARK: - Initialization + + init(getNavView: @escaping () -> GoogleMapsNavigationView?) { + self.getNavView = getNavView + super.init() + } + + // MARK: - Listener Management + + func attachListener() -> Bool { + NSLog("πŸ”Ά [ListenerCoordinator] attachListener() called") + + if isListenerAttached { + NSLog("πŸ”Ά [ListenerCoordinator] attachListener() - already attached") + return false + } + + guard let navView = getNavView(), + let mapView = navView.view() as? GMSMapView, + let navigator = mapView.navigator + else { + NSLog("πŸ”Ά [ListenerCoordinator] attachListener() - views/navigator not ready yet") + return false + } + + NSLog("πŸ”Ά [ListenerCoordinator] attachListener() - attaching listener") + navigator.remove(self) + navigator.add(self) + isListenerAttached = true + + // Initialize cached values if navigation is active + if navigator.isGuidanceActive, navigator.currentRouteLeg != nil { + NSLog( + "πŸ”Ά [ListenerCoordinator] attachListener() - navigation active, initializing cached values" + ) + cachedRemainingTime = navigator.timeToNextDestination + cachedRemainingDistance = navigator.distanceToNextDestination + NSLog( + "πŸ”Ά [ListenerCoordinator] attachListener() - initialized time: \(cachedRemainingTime ?? 0)s, distance: \(cachedRemainingDistance ?? 0)m" + ) + + // Notify delegate of initial values + if let time = cachedRemainingTime { + delegate?.navigatorDidUpdateRemainingTime(time) + } + if let distance = cachedRemainingDistance { + delegate?.navigatorDidUpdateRemainingDistance(distance) + } + } + + return true + } + + func detachListener() { + NSLog("πŸ”Ά [ListenerCoordinator] detachListener() called") + guard let navView = getNavView(), + let mapView = navView.view() as? GMSMapView, + let navigator = mapView.navigator + else { + return + } + + navigator.remove(self) + isListenerAttached = false + } + + func reattachListenerAfterDelay(delay: TimeInterval = 0.1) { + NSLog("πŸ”Ά [ListenerCoordinator] reattachListenerAfterDelay(\(delay)s) called") + detachListener() + + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + NSLog( + "πŸ”Ά [ListenerCoordinator] reattachListenerAfterDelay() asyncAfter - re-attaching listener" + ) + _ = self?.attachListener() + } + } + + // MARK: - GMSNavigatorListener + + func navigator(_ navigator: GMSNavigator, didUpdateRemainingTime remainingTime: TimeInterval) { + cachedRemainingTime = remainingTime + delegate?.navigatorDidUpdateRemainingTime(remainingTime) + } + + func navigator( + _ navigator: GMSNavigator, didUpdateRemainingDistance remainingDistance: CLLocationDistance + ) { + cachedRemainingDistance = remainingDistance + delegate?.navigatorDidUpdateRemainingDistance(remainingDistance) + } + + func navigator(_ navigator: GMSNavigator, didUpdate navInfo: GMSNavigationNavInfo) { + NSLog("πŸ”Ά [ListenerCoordinator] navigator didUpdate navInfo - isGuidanceActive: \(navigator.isGuidanceActive)") + + // Cache the latest navInfo + cachedNavInfo = navInfo + + if navigator.isGuidanceActive { + let nextManeuver = navInfo.remainingSteps.first?.maneuver + NSLog("πŸ”Ά [ListenerCoordinator] Forwarding navInfo to delegate - steps: \(navInfo.remainingSteps.count)") + delegate?.navigatorDidUpdateNavInfo(navInfo, nextManeuver: nextManeuver) + } + } + + /// Get the last cached navInfo (useful for initializing CarPlay session) + func getCachedNavInfo() -> GMSNavigationNavInfo? { + return cachedNavInfo + } + + func navigatorDidChangeRoute(_ navigator: GMSNavigator) { + NSLog("πŸ—ΊοΈ [ListenerCoordinator] navigatorDidChangeRoute() called") + delegate?.navigatorDidChangeRoute() + reattachListenerAfterDelay() + } + + // MARK: - Cleanup + + func cleanup() { + detachListener() + cachedRemainingTime = nil + cachedRemainingDistance = nil + cachedNavInfo = nil + } +} diff --git a/example/ios/Runner/CarPlayNavigationSessionManager.swift b/example/ios/Runner/CarPlayNavigationSessionManager.swift new file mode 100644 index 00000000..790e5913 --- /dev/null +++ b/example/ios/Runner/CarPlayNavigationSessionManager.swift @@ -0,0 +1,546 @@ +import CarPlay +import CoreLocation +import GoogleNavigation +import MapKit +import UIKit + +/// Manager for native CarPlay navigation session (maneuvers, ETA, lane panel in 2nd maneuver) +final class CarPlayNavigationSessionManager: NSObject { + + // MARK: - Properties + + private var navigationSession: CPNavigationSession? + private var currentTrip: CPTrip? + private var lastRouteHash: String? + + private weak var mapTemplate: CPMapTemplate? + private weak var mapView: GMSMapView? + + /// We need to identify the "lane guidance maneuver" to return `.symbolOnly` + /// because the display style only applies to the *second* maneuver. [oai_citation:7‑CarPlay-Developer-Guide.pdf](sediment://file_000000003dc471f5944f8194e030d81e) + private weak var currentLaneGuidanceManeuver: CPManeuver? + + private var cachedLaneSignature: String? + private weak var cachedLaneManeuver: CPManeuver? + + private var cachedNextSignature: String? + private weak var cachedNextManeuver: CPManeuver? + + // MARK: - Initialization + + init(mapTemplate: CPMapTemplate?, mapView: GMSMapView?) { + self.mapTemplate = mapTemplate + self.mapView = mapView + super.init() + } + // MARK: - Public Methods + + /// Start the native CPNavigationSession + func startSession(getCachedNavInfo: (() -> GMSNavigationNavInfo?)? = nil) { + guard navigationSession == nil else { + NSLog("πŸš— [NavigationSession] Session already active, skipping") + return + } + + guard let mapView = mapView, + let navigator = mapView.navigator, + let template = mapTemplate else { + NSLog("πŸš— [NavigationSession] Cannot start - mapView:\(mapView != nil) navigator:\(mapView?.navigator != nil) template:\(mapTemplate != nil)") + return + } + + NSLog("πŸš— [NavigationSession] Starting native CPNavigationSession...") + NSLog("πŸš— [NavigationSession] Template valid: \(template)") + + // Create trip + let currentLocation = mapView.myLocation?.coordinate ?? CLLocationCoordinate2D(latitude: 0, longitude: 0) + let origin = MKMapItem(placemark: MKPlacemark(coordinate: currentLocation)) + origin.name = "Current Location" + + let destinationLocation = navigator.currentRouteLeg?.destinationCoordinate ?? currentLocation + let destination = MKMapItem(placemark: MKPlacemark(coordinate: destinationLocation)) + destination.name = "Destination" + + let trip = CPTrip(origin: origin, destination: destination, routeChoices: []) + currentTrip = trip + + let session = template.startNavigationSession(for: trip) + navigationSession = session + + NSLog("πŸš— [NavigationSession] βœ… Started! Session: \(session)") + + // Try to trigger initial update with maneuvers if navInfo is available + triggerInitialUpdate(getCachedNavInfo: getCachedNavInfo) + } + + /// Stop the native CPNavigationSession + func stopSession() { + guard let session = navigationSession else { return } + + session.finishTrip() + navigationSession = nil + currentTrip = nil + lastRouteHash = nil + currentLaneGuidanceManeuver = nil + + NSLog("πŸš— [NavigationSession] Session stopped") + } + + /// Update ETA and maneuvers with navigation info + func updateNavigation( + distanceToFinal: CLLocationDistance, + timeToFinal: TimeInterval, + navInfo: GMSNavigationNavInfo + ) { + guard let session = navigationSession, + let trip = currentTrip, + let template = mapTemplate else { + NSLog("πŸš— [NavigationSession] ⚠️ Cannot update - session:\(navigationSession != nil) trip:\(currentTrip != nil) template:\(mapTemplate != nil)") + return + } + + // 1) ETA trip + guard distanceToFinal > 0, timeToFinal > 0 else { + NSLog("πŸš— [NavigationSession] πŸ“Š Skipping ETA update - invalid distance/time") + return + } + + let distanceRemaining = formatDistance(distanceToFinal) + let formattedTime = formatTime(timeToFinal) + + template.updateEstimates( + CPTravelEstimates(distanceRemaining: distanceRemaining, timeRemaining: formattedTime), + for: trip + ) + + // 2) Maneuvers (+ lane panel as 2nd maneuver if lanes available) + updateManeuver(from: navInfo, session: session) + } + + /// Check if session is active + var isSessionActive: Bool { navigationSession != nil } + + /// Update references + func updateReferences(mapTemplate: CPMapTemplate?, mapView: GMSMapView?) { + self.mapTemplate = mapTemplate + self.mapView = mapView + } + + /// Used by CPMapTemplateDelegate (CarSceneDelegate) to return .symbolOnly for the lane guidance maneuver. + func isLaneGuidanceManeuver(_ maneuver: CPManeuver) -> Bool { + guard let lane = currentLaneGuidanceManeuver else { return false } + return maneuver === lane + } + + /// Cleanup resources + func cleanup() { + stopSession() + mapTemplate = nil + mapView = nil + } + + // MARK: - Private + + private func triggerInitialUpdate(getCachedNavInfo: (() -> GMSNavigationNavInfo?)? = nil) { + guard let mapView = mapView, + let navigator = mapView.navigator, + navigator.isGuidanceActive, + let session = navigationSession else { + NSLog("πŸš— [NavigationSession] Cannot trigger initial update - not ready") + return + } + + let distanceToFinal = navigator.distanceToNextDestination + let timeToFinal = navigator.timeToNextDestination + + // Update ETA + if distanceToFinal > 0, timeToFinal > 0, let trip = currentTrip, let template = mapTemplate { + let travelEstimates = CPTravelEstimates( + distanceRemaining: formatDistance(distanceToFinal), + timeRemaining: formatTime(timeToFinal) + ) + template.updateEstimates(travelEstimates, for: trip) + NSLog("πŸš— [NavigationSession] πŸ“Š Initial ETA sent") + } + + // Try to update maneuvers immediately if navInfo is available + if let getNavInfo = getCachedNavInfo, let navInfo = getNavInfo() { + NSLog("πŸš— [NavigationSession] πŸ“ Initial navInfo available, updating maneuvers immediately") + updateManeuver(from: navInfo, session: session) + } else { + NSLog("πŸš— [NavigationSession] ⏳ No cached navInfo available, will wait for first update") + } + } + + private func laneSignature(from lanes: [GMSNavigationLane]) -> String { + // stable: suite de shapes+recommended pour chaque lane + lanes.map { lane in + lane.laneDirections.map { dir in + "\(dir.laneShape.rawValue):\(dir.recommended ? 1 : 0)" + }.joined(separator: ",") + }.joined(separator: "|") + } + + private func getOrCreateLaneManeuver(lanes: [GMSNavigationLane]) -> CPManeuver? { + let sig = laneSignature(from: lanes) + if sig == cachedLaneSignature, let cached = cachedLaneManeuver { + return cached + } + guard let created = createLaneGuidanceSecondManeuver(from: lanes) else { return nil } + cachedLaneSignature = sig + cachedLaneManeuver = created + return created + } + + private func nextSignature(step: GMSNavigationStepInfo) -> String { + // stable enough to reuse the next maneuver object + let name = step.simpleRoadName ?? step.fullInstructionText ?? "Continue" + return "\(name)#\(Int(step.distanceFromPrevStepMeters))#\(step.maneuver.rawValue)" + } + + private func getOrCreateNextManeuver(step: GMSNavigationStepInfo) -> CPManeuver { + let sig = nextSignature(step: step) + if sig == cachedNextSignature, let cached = cachedNextManeuver { + return cached + } + let created = createPrimaryManeuver( + from: step, + distance: CLLocationDistance(step.distanceFromPrevStepMeters), + stepName: step.simpleRoadName ?? step.fullInstructionText ?? "Continue" + ) + cachedNextSignature = sig + cachedNextManeuver = created + return created + } + + private func updateManeuver(from navInfo: GMSNavigationNavInfo, session: CPNavigationSession) { + guard let currentStep = navInfo.currentStep else { + NSLog("πŸš— [NavigationSession] ⚠️ No current step available") + return + } + + let currentStepName = currentStep.simpleRoadName ?? currentStep.fullInstructionText ?? "Continue" + let distanceToCurrentStep = CLLocationDistance(navInfo.distanceToCurrentStepMeters) + + // Detect route change (coarse but stable) + let remainingStepsKey = navInfo.remainingSteps.prefix(5).map { + $0.simpleRoadName ?? $0.fullInstructionText ?? "?" + }.joined(separator: "|") + let routeHash = "\(currentStepName)_\(remainingStepsKey)" + + // ---------- Helpers (local, no extra state needed) ---------- + // Hysteresis avoids flicker around thresholds. + // Keep last state by using `currentLaneGuidanceManeuver != nil` as a weak β€œmemory” for lanes visibility. + func shouldShowLanePanel(distance: CLLocationDistance) -> Bool { + // show at <= 900m, hide only when > 1100m + let showThreshold: CLLocationDistance = 900 + let hideThreshold: CLLocationDistance = 1100 + + let wasShowing = (currentLaneGuidanceManeuver != nil) + if wasShowing { + return distance <= hideThreshold + } else { + return distance <= showThreshold + } + } + + func shouldShowNextTurn(distance: CLLocationDistance) -> Bool { + // show at <= 700m, hide only when > 900m + let showThreshold: CLLocationDistance = 700 + let hideThreshold: CLLocationDistance = 900 + + // We consider "wasShowingNext" as: we were not showing lanes, and we had at least 2 maneuvers previously. + // (Not perfect, but works without adding persistent state.) + let wasShowingNext = (currentLaneGuidanceManeuver == nil) && (session.upcomingManeuvers.count >= 2) + + if wasShowingNext { + return distance <= hideThreshold + } else { + return distance <= showThreshold + } + } + + func buildPrimaryManeuver(step: GMSNavigationStepInfo, distance: CLLocationDistance) -> CPManeuver { + let name = step.simpleRoadName ?? step.fullInstructionText ?? "Continue" + return createPrimaryManeuver(from: step, distance: distance, stepName: name) + } + + // ---------- Route change: rebuild maneuvers ---------- + if routeHash != lastRouteHash { + lastRouteHash = routeHash + cachedLaneSignature = nil + cachedLaneManeuver = nil + cachedNextSignature = nil + cachedNextManeuver = nil + NSLog("πŸš— [NavigationSession] πŸ”„ Route changed, rebuilding maneuvers...") + + // Always compute full route list (your internal β€œcomplete logic”) + var allPrimary: [CPManeuver] = [] + allPrimary.append(buildPrimaryManeuver(step: currentStep, distance: distanceToCurrentStep)) + + let maxRemainingSteps = min(navInfo.remainingSteps.count, 10) + for i in 0..= 2 { + if let nextStep = navInfo.remainingSteps.first { + published.append(getOrCreateNextManeuver(step: nextStep)) + } + } + + session.upcomingManeuvers = published + NSLog("πŸš— [NavigationSession] βœ… Published \(published.count) maneuver(s) (lane panel priority)") + return + } + + // ---------- Same route: update distance for current maneuver and refresh 2nd slot if needed ---------- + guard distanceToCurrentStep > 10, let first = session.upcomingManeuvers.first else { return } + + let updated = CPTravelEstimates(distanceRemaining: formatDistance(distanceToCurrentStep), timeRemaining: 0) + session.updateEstimates(updated, for: first) + + // Optionally refresh the published list even when route doesn't change, + // so lanes/next appear at the right time as you approach. + // This avoids β€œlanes too early” and also allows next turn to appear later. + let wantLanes = shouldShowLanePanel(distance: distanceToCurrentStep) + let wantNext = shouldShowNextTurn(distance: distanceToCurrentStep) + + var published: [CPManeuver] = [first] + var usedSecondSlot = false + + if wantLanes, + let lanes = currentStep.lanes, !lanes.isEmpty, + let laneManeuver = getOrCreateLaneManeuver(lanes: lanes) { + + published.append(laneManeuver) + currentLaneGuidanceManeuver = laneManeuver + usedSecondSlot = true + + } else { + currentLaneGuidanceManeuver = nil + } + + if !usedSecondSlot, wantNext, let next = navInfo.remainingSteps.first { + published.append(getOrCreateNextManeuver(step: next)) + } + + // Only assign if something changed to reduce UI churn + let sameCount = (session.upcomingManeuvers.count == published.count) + let sameSecond = (published.count < 2 && session.upcomingManeuvers.count < 2) + || (published.count >= 2 && session.upcomingManeuvers.count >= 2 + && (session.upcomingManeuvers[1] === published[1])) + + if !(sameCount && sameSecond) { + session.upcomingManeuvers = published + NSLog("πŸš— [NavigationSession] πŸ” Refreshed published maneuvers -> \(published.count)") + } + } + + // MARK: - Maneuver builders + + private func createPrimaryManeuver( + from step: GMSNavigationStepInfo, + distance: CLLocationDistance, + stepName: String + ) -> CPManeuver { + let maneuver = CPManeuver() + + maneuver.instructionVariants = [stepName] + maneuver.symbolImage = symbolImage(for: step.maneuver) + maneuver.initialTravelEstimates = CPTravelEstimates( + distanceRemaining: formatDistance(distance), + timeRemaining: 0 + ) + + return maneuver + } + + /// Creates the special SECOND maneuver used to show lane guidance on the CarPlay screen. + /// Doc: symbolSet with dark/light images, full width max 120ptΓ—18pt, instructionVariants = [], return .symbolOnly in delegate. + private func createLaneGuidanceSecondManeuver(from lanes: [GMSNavigationLane]) -> CPManeuver? { + guard let light = renderLaneGuidanceImage(lanes: lanes, isDark: false), + let dark = renderLaneGuidanceImage(lanes: lanes, isDark: true) else { + return nil + } + + let m = CPManeuver() + m.instructionVariants = [] // required for lane guidance second maneuver + m.symbolSet = CPImageSet(lightContentImage: light, darkContentImage: dark) + // Required so CarPlay allocates the row for the symbol; doc says maneuver may include estimates. + m.initialTravelEstimates = CPTravelEstimates( + distanceRemaining: Measurement(value: 0, unit: .meters), + timeRemaining: 0 + ) + return m + } + + // MARK: - Lane image rendering (120pt x 18pt max) + + /// Doc: second maneuver symbol (symbol only) max 120ptΓ—18pt; provide light and dark variants. + /// Render at 2x scale so the symbol is sharp on CarPlay displays. + private func renderLaneGuidanceImage(lanes: [GMSNavigationLane], isDark: Bool) -> UIImage? { + let sizePt = CGSize(width: 120, height: 18) // points (doc max) + let scale: CGFloat = 2 + let format = UIGraphicsImageRendererFormat() + format.scale = scale + format.opaque = false + let renderer = UIGraphicsImageRenderer(size: sizePt, format: format) + return renderer.image { ctx in + let cg = ctx.cgContext + cg.clear(CGRect(origin: .zero, size: sizePt)) + + // Layout (in point space; context is scaled) + let laneCount = max(1, min(lanes.count, 8)) + let padding: CGFloat = 2 + let availableWidth = sizePt.width - (padding * 2) + let laneWidth = availableWidth / CGFloat(laneCount) + let laneHeight: CGFloat = 12 + let topY: CGFloat = (sizePt.height - laneHeight) / 2 + + // Colors: lightContentImage = for light backgrounds (use dark strokes), darkContentImage = for dark backgrounds (use light strokes) + let neutral = (isDark ? UIColor(white: 1.0, alpha: 0.6) : UIColor(white: 0.0, alpha: 0.5)) + let preferred = (isDark ? UIColor.white : UIColor.black) + + for i in 0.. Measurement { + if distance >= 1000 { + let km = distance / 1000.0 + let roundedKm = round(km * 10) / 10 + return Measurement(value: roundedKm, unit: .kilometers) + } else if distance >= 100 { + let roundedMeters = round(distance / 50.0) * 50.0 + return Measurement(value: roundedMeters, unit: .meters) + } else { + let roundedMeters = round(distance / 10.0) * 10.0 + return Measurement(value: roundedMeters, unit: .meters) + } + } + + private func formatTime(_ time: TimeInterval) -> TimeInterval { + let minutes = Int(time / 60.0) + let seconds = Int(time.truncatingRemainder(dividingBy: 60)) + + let roundedMinutes: Int + if time < 60 { roundedMinutes = 1 } + else if seconds >= 30 { roundedMinutes = minutes + 1 } + else { roundedMinutes = minutes } + + return TimeInterval(roundedMinutes * 60) + } + + private func symbolImage(for maneuver: GMSNavigationManeuver) -> UIImage? { + let symbolName: String + switch maneuver { + case .destination: symbolName = "flag.checkered" + case .depart: symbolName = "arrow.up.circle" + case .straight: symbolName = "arrow.up" + case .turnLeft, .turnSharpLeft, .turnSlightLeft: symbolName = "arrow.turn.up.left" + case .turnRight, .turnSharpRight, .turnSlightRight: symbolName = "arrow.turn.up.right" + case .onRampLeft, .offRampLeft: symbolName = "arrow.up.left" + case .onRampRight, .offRampRight: symbolName = "arrow.up.right" + case .turnUTurnClockwise: symbolName = "arrow.uturn.left" + case .mergeLeft, .mergeRight: symbolName = "arrow.merge" + case .roundaboutClockwise: symbolName = "arrow.triangle.2.circlepath" + case .ferryBoat: symbolName = "ferry" + default: symbolName = "arrow.up" + } + return UIImage(systemName: symbolName) + } +} + diff --git a/example/ios/Runner/CarPlayNavigationStateMonitor.swift b/example/ios/Runner/CarPlayNavigationStateMonitor.swift new file mode 100644 index 00000000..9b059f97 --- /dev/null +++ b/example/ios/Runner/CarPlayNavigationStateMonitor.swift @@ -0,0 +1,159 @@ +import Foundation +import GoogleNavigation +import google_navigation_flutter + +protocol CarPlayNavigationStateMonitorDelegate: AnyObject { + func navigationStateDidChange(isReady: Bool) + func guidanceStateDidChange(isActive: Bool) + func viewsDidBecomeAvailable() +} + +class CarPlayNavigationStateMonitor { + // MARK: - Properties + + weak var delegate: CarPlayNavigationStateMonitorDelegate? + private var getNavView: () -> GoogleMapsNavigationView? + private var stateCheckTimer: Timer? + private var isMonitoringBasicState = false + private(set) var isNavigationReady: Bool = false + private(set) var lastGuidanceState: Bool = false + + // MARK: - Initialization + + init(getNavView: @escaping () -> GoogleMapsNavigationView?) { + self.getNavView = getNavView + } + + // MARK: - State Checking + + func checkNavigationReady() { + guard let navView = getNavView(), + let mapView = navView.view() as? GMSMapView, + let navigator = mapView.navigator + else { + NSLog( + "βœ… [StateMonitor] checkNavigationReady() - views/navigator not available, NOT READY" + ) + isNavigationReady = false + return + } + + let hasRoute = navigator.currentRouteLeg != nil + isNavigationReady = hasRoute + NSLog( + "βœ… [StateMonitor] checkNavigationReady() - currentRouteLeg: \(hasRoute ? "YES" : "NO"), isNavigationReady: \(isNavigationReady)" + ) + } + + func areViewsAvailable() -> Bool { + guard let navView = getNavView(), + let mapView = navView.view() as? GMSMapView, + mapView.superview != nil + else { + return false + } + return true + } + + // MARK: - Monitoring + + func startBasicMonitoring() { + NSLog("🟀 [StateMonitor] startBasicMonitoring() - starting fallback monitoring") + + guard !isMonitoringBasicState else { + NSLog("🟀 [StateMonitor] startBasicMonitoring() - already monitoring, skipping") + return + } + + isMonitoringBasicState = true + + stateCheckTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { + [weak self] timer in + guard let self = self else { + timer.invalidate() + return + } + + if self.areViewsAvailable() { + NSLog("🟀 [StateMonitor] Timer - views now available") + timer.invalidate() + self.stateCheckTimer = nil + self.isMonitoringBasicState = false + self.delegate?.viewsDidBecomeAvailable() + } + } + } + + func startFullStateMonitoring() { + NSLog("⏱️ [StateMonitor] startFullStateMonitoring() called") + stateCheckTimer?.invalidate() + isMonitoringBasicState = false + + guard let navView = getNavView(), + let mapView = navView.view() as? GMSMapView, + let navigator = mapView.navigator + else { + NSLog("⏱️ [StateMonitor] startFullStateMonitoring() - views not ready") + return + } + + let initialGuidanceState = navigator.isGuidanceActive + lastGuidanceState = initialGuidanceState + + // If guidance is already active when monitoring starts, trigger the delegate immediately + // This handles the case where CarPlay connects after navigation has already started + if initialGuidanceState { + NSLog("⏱️ [StateMonitor] startFullStateMonitoring() - guidance already active, triggering delegate") + delegate?.guidanceStateDidChange(isActive: true) + } + + stateCheckTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { + [weak self] timer in + guard let self = self else { + timer.invalidate() + return + } + + guard let navView = self.getNavView(), + let mapView = navView.view() as? GMSMapView, + let navigator = mapView.navigator + else { + return + } + + let wasReady = self.isNavigationReady + self.checkNavigationReady() + + // Handle navigation ready state changes + if wasReady != self.isNavigationReady { + NSLog( + "⏱️ [StateMonitor] Timer - navigation ready changed to: \(self.isNavigationReady)" + ) + self.delegate?.navigationStateDidChange(isReady: self.isNavigationReady) + } + + // Handle guidance state changes + let currentState = navigator.isGuidanceActive + if self.lastGuidanceState != currentState { + NSLog("⏱️ [StateMonitor] Timer - guidance state changed to: \(currentState)") + self.lastGuidanceState = currentState + self.delegate?.guidanceStateDidChange(isActive: currentState) + } + } + } + + func stopMonitoring() { + NSLog("⏹️ [StateMonitor] stopMonitoring() called") + stateCheckTimer?.invalidate() + stateCheckTimer = nil + isMonitoringBasicState = false + } + + // MARK: - Cleanup + + func cleanup() { + stopMonitoring() + isNavigationReady = false + lastGuidanceState = false + } +} diff --git a/example/ios/Runner/CarPlayTemplateManager.swift b/example/ios/Runner/CarPlayTemplateManager.swift new file mode 100644 index 00000000..547b6311 --- /dev/null +++ b/example/ios/Runner/CarPlayTemplateManager.swift @@ -0,0 +1,132 @@ +import CarPlay +import UIKit + +protocol CarPlayTemplateManagerDelegate: AnyObject { + func templateManagerDidRequestNavigationReady() +} + +class CarPlayTemplateManager { + // MARK: - Properties + + weak var delegate: CarPlayTemplateManagerDelegate? + private weak var interfaceController: CPInterfaceController? + private weak var currentMapTemplate: CPMapTemplate? + private var waitingTemplate: CPInformationTemplate? + private var mapTemplateForRestore: CPMapTemplate? + + // MARK: - Initialization + + init(interfaceController: CPInterfaceController?) { + self.interfaceController = interfaceController + } + + func updateInterfaceController(_ interfaceController: CPInterfaceController?) { + self.interfaceController = interfaceController + } + + // MARK: - Template Creation + + func createInitialMapTemplate() -> CPMapTemplate { + NSLog("πŸ”΅ [TemplateManager] createInitialMapTemplate() called") + let template = CPMapTemplate() + template.dismissPanningInterface(animated: false) + template.automaticallyHidesNavigationBar = true + currentMapTemplate = template + return template + } + + // MARK: - Template Switching + + func showWaitingTemplate() { + NSLog("🟑 [TemplateManager] showWaitingTemplate() called") + + guard let interfaceController = interfaceController else { + NSLog("🟑 [TemplateManager] showWaitingTemplate() - no interfaceController yet") + return + } + + if waitingTemplate != nil { + NSLog("🟑 [TemplateManager] showWaitingTemplate() - already showing, skipping") + return + } + + NSLog("🟑 [TemplateManager] showWaitingTemplate() - creating and showing waiting template") + let informationItem = CPInformationItem( + title: "Waiting", + detail: "Waiting for navigation session..." + ) + + waitingTemplate = CPInformationTemplate( + title: "Navigation", + layout: .leading, + items: [informationItem], + actions: [] + ) + + if let waitingTemplate = waitingTemplate, let currentTemplate = currentMapTemplate { + mapTemplateForRestore = currentTemplate + + interfaceController.setRootTemplate(waitingTemplate, animated: true) { + [weak self] success, error in + if let error = error { + NSLog("🟑 [TemplateManager] showWaitingTemplate() - error: \(error)") + } else { + NSLog("🟑 [TemplateManager] showWaitingTemplate() - success") + } + } + } + } + + func switchToMapTemplate() { + NSLog("🟒 [TemplateManager] switchToMapTemplate() called") + guard let interfaceController = interfaceController else { + NSLog("🟒 [TemplateManager] switchToMapTemplate() - no interfaceController") + return + } + + guard waitingTemplate != nil else { + NSLog("🟒 [TemplateManager] switchToMapTemplate() - no waiting template, skipping") + return + } + + NSLog("🟒 [TemplateManager] switchToMapTemplate() - switching from waiting to map template") + let mapTemplate = + mapTemplateForRestore + ?? { + let template = CPMapTemplate() + template.dismissPanningInterface(animated: false) + template.automaticallyHidesNavigationBar = true + return template + }() + + currentMapTemplate = mapTemplate + mapTemplateForRestore = nil + + interfaceController.setRootTemplate(mapTemplate, animated: true) { + [weak self] success, error in + if let error = error { + NSLog("🟒 [TemplateManager] switchToMapTemplate() - error: \(error)") + } else { + NSLog("🟒 [TemplateManager] switchToMapTemplate() - success") + self?.waitingTemplate = nil + } + } + } + + func isShowingWaitingTemplate() -> Bool { + return waitingTemplate != nil + } + + func getCurrentMapTemplate() -> CPMapTemplate? { + return currentMapTemplate + } + + // MARK: - Cleanup + + func cleanup() { + NSLog("πŸ”΄ [TemplateManager] cleanup() called") + waitingTemplate = nil + mapTemplateForRestore = nil + currentMapTemplate = nil + } +} diff --git a/example/ios/Runner/CarPlayWaitingOverlayManager.swift b/example/ios/Runner/CarPlayWaitingOverlayManager.swift new file mode 100644 index 00000000..b5367a85 --- /dev/null +++ b/example/ios/Runner/CarPlayWaitingOverlayManager.swift @@ -0,0 +1,194 @@ +import CarPlay +import UIKit + +/// Manager for displaying and hiding the waiting overlay in CarPlay +class CarPlayWaitingOverlayManager { + // MARK: - Properties + + private weak var carWindow: CPWindow? + private weak var mapTemplate: CPMapTemplate? + private var overlayView: UIView? + private var savedTrailingButtons: [CPBarButton]? + private var savedLeadingButtons: [CPBarButton]? + + // MARK: - Initialization + + init(carWindow: CPWindow?, mapTemplate: CPMapTemplate? = nil) { + self.carWindow = carWindow + self.mapTemplate = mapTemplate + } + + // MARK: - Public Methods + + /// Show the waiting overlay with custom title and message + /// - Parameters: + /// - mainTitle: The main title to display (default: "Navigation") + /// - title: The secondary title to display (default: "Waiting") + /// - message: The message to display (default: "Waiting for navigation session...") + func show( + mainTitle: String = "Navigation", + title: String = "Waiting", + message: String = "Waiting for navigation session..." + ) { + guard overlayView == nil else { + NSLog("🟑 [WaitingOverlay] Already shown") + return + } + + guard let carWindow = carWindow else { + NSLog("🟑 [WaitingOverlay] ❌ CarPlay window not available") + return + } + + NSLog("🟑 [WaitingOverlay] Showing overlay: \(mainTitle) - \(title) - \(message)") + + // Create overlay container + let overlay = UIView(frame: carWindow.bounds) + overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight] + + // Create gradient background (teal/blue gradient like in the image) + let gradientLayer = CAGradientLayer() + gradientLayer.frame = carWindow.bounds + gradientLayer.colors = [ + UIColor(red: 0.2, green: 0.5, blue: 0.5, alpha: 1.0).cgColor, // Teal + UIColor(red: 0.15, green: 0.2, blue: 0.35, alpha: 1.0).cgColor // Dark blue + ] + gradientLayer.locations = [0.0, 1.0] + gradientLayer.startPoint = CGPoint(x: 0.0, y: 0.0) + gradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0) + overlay.layer.insertSublayer(gradientLayer, at: 0) + + // Create content container (top-left aligned like in the image) + let contentView = UIView() + contentView.translatesAutoresizingMaskIntoConstraints = false + overlay.addSubview(contentView) + + // Create main title label (big "Navigation") + let mainTitleLabel = UILabel() + mainTitleLabel.text = mainTitle + mainTitleLabel.font = UIFont.systemFont(ofSize: 32, weight: .bold) + mainTitleLabel.textColor = .white + mainTitleLabel.textAlignment = .left + mainTitleLabel.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(mainTitleLabel) + + // Create secondary title label ("Waiting") + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.font = UIFont.systemFont(ofSize: 16, weight: .regular) + titleLabel.textColor = UIColor.white.withAlphaComponent(0.8) + titleLabel.textAlignment = .left + titleLabel.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(titleLabel) + + // Create message label + let messageLabel = UILabel() + messageLabel.text = message + messageLabel.font = UIFont.systemFont(ofSize: 14, weight: .regular) + messageLabel.textColor = UIColor.white.withAlphaComponent(0.7) + messageLabel.textAlignment = .left + messageLabel.numberOfLines = 0 + messageLabel.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(messageLabel) + + // Setup constraints with padding + NSLayoutConstraint.activate([ + // Position content view with custom padding + contentView.leadingAnchor.constraint(equalTo: overlay.safeAreaLayoutGuide.leadingAnchor, constant: 24), + contentView.topAnchor.constraint(equalTo: overlay.safeAreaLayoutGuide.topAnchor, constant: 16), + contentView.trailingAnchor.constraint(equalTo: overlay.safeAreaLayoutGuide.trailingAnchor, constant: -24), + + // Main title at top + mainTitleLabel.topAnchor.constraint(equalTo: contentView.topAnchor), + mainTitleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + mainTitleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + + // Secondary title below main title + titleLabel.topAnchor.constraint(equalTo: mainTitleLabel.bottomAnchor, constant: 20), + titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + + // Message below secondary title + messageLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4), + messageLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + messageLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + messageLabel.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor) + ]) + + // Add overlay to window + carWindow.addSubview(overlay) + overlayView = overlay + + // Hide ALL navigation bar buttons (both leading and trailing) + // AND disable auto-hide during overlay + if let template = mapTemplate { + savedLeadingButtons = template.leadingNavigationBarButtons + savedTrailingButtons = template.trailingNavigationBarButtons + template.leadingNavigationBarButtons = [] + template.trailingNavigationBarButtons = [] + template.automaticallyHidesNavigationBar = false + NSLog("🟑 [WaitingOverlay] Hidden \(savedLeadingButtons?.count ?? 0) leading + \(savedTrailingButtons?.count ?? 0) trailing buttons + disabled auto-hide") + } + + NSLog("🟑 [WaitingOverlay] βœ… Overlay shown") + } + + /// Hide the waiting overlay + func hide() { + guard let overlay = overlayView else { + NSLog("🟒 [WaitingOverlay] No overlay to hide") + return + } + + NSLog("🟒 [WaitingOverlay] Hiding overlay...") + + // Mark as hidden immediately (before animation) + overlayView = nil + + // Re-enable auto-hide for navigation bar + if let template = mapTemplate { + template.automaticallyHidesNavigationBar = true + NSLog("🟒 [WaitingOverlay] Re-enabled auto-hide for navigation bar") + } + + // Clear saved buttons (they will be recreated by updateTemplateButtons) + savedLeadingButtons = nil + savedTrailingButtons = nil + + NSLog("🟒 [WaitingOverlay] βœ… Overlay hidden, buttons will be restored by updateTemplateButtons()") + + // Animate fade out (just visual) + UIView.animate(withDuration: 0.3, animations: { + overlay.alpha = 0 + }, completion: { _ in + overlay.removeFromSuperview() + NSLog("🟒 [WaitingOverlay] Animation complete, overlay removed") + }) + } + + /// Check if the overlay is currently shown + var isShown: Bool { + return overlayView != nil + } + + /// Update references + func updateReferences(carWindow: CPWindow?, mapTemplate: CPMapTemplate?) { + self.carWindow = carWindow + self.mapTemplate = mapTemplate + } + + /// Cleanup resources + func cleanup() { + // If overlay is still shown, hide it first (which will handle buttons cleanup) + if overlayView != nil { + hide() + } + + overlayView?.removeFromSuperview() + overlayView = nil + savedLeadingButtons = nil + savedTrailingButtons = nil + carWindow = nil + mapTemplate = nil + } +} diff --git a/example/ios/Runner/CarSceneDelegate.swift b/example/ios/Runner/CarSceneDelegate.swift index dfc2451b..73d76343 100644 --- a/example/ios/Runner/CarSceneDelegate.swift +++ b/example/ios/Runner/CarSceneDelegate.swift @@ -1,38 +1,387 @@ -// Copyright 2023 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// https://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - import CarPlay +import CoreLocation import GoogleNavigation +import MapKit import UIKit import google_navigation_flutter class CarSceneDelegate: BaseCarSceneDelegate { - override func getTemplate() -> CPMapTemplate { - let template = CPMapTemplate() - template.showPanningInterface(animated: true) - - let customEventButton = CPBarButton(title: "Custom Event") { [weak self] _ in - let data = ["sampleDataKey": "sampleDataContent"] - self?.sendCustomNavigationAutoEvent(event: "CustomCarPlayEvent", data: data) - } - let recenterButton = CPBarButton(title: "Re-center") { [weak self] _ in - self?.getNavView()?.followMyLocation( - perspective: GMSNavigationCameraPerspective.tilted, - zoomLevel: nil - ) - } - template.leadingNavigationBarButtons = [customEventButton, recenterButton] - return template - } + // MARK: - Managers + + private var stateMonitor: CarPlayNavigationStateMonitor? + private var listenerCoordinator: CarPlayNavigationListenerCoordinator? + private var buttonFactory: CarPlayButtonFactory? + private var waitingOverlayManager: CarPlayWaitingOverlayManager? + private var navigationSessionManager: CarPlayNavigationSessionManager? + + // Keep reference to the single map template we use throughout + private weak var mapTemplate: CPMapTemplate? + + // MARK: - Template Creation + + override func getTemplate() -> CPMapTemplate { + NSLog("πŸ”΅ [CarPlay] getTemplate() called") + + // Initialize managers + initializeManagers() + + // Create simple map template + let template = CPMapTemplate() + template.dismissPanningInterface(animated: false) + template.automaticallyHidesNavigationBar = false // Will be managed by WaitingOverlayManager + mapTemplate = template + + // Update references in navigation session manager + navigationSessionManager?.updateReferences(mapTemplate: template, mapView: getMapView()) + + stateMonitor?.checkNavigationReady() + NSLog("πŸ”΅ [CarPlay] getTemplate() - isNavigationReady: \(stateMonitor?.isNavigationReady ?? false)") + + return template + } + + // MARK: - Managers Initialization + + private func initializeManagers() { + guard stateMonitor == nil else { return } + + NSLog("πŸ”΅ [CarPlay] initializeManagers() called") + + stateMonitor = CarPlayNavigationStateMonitor(getNavView: { [weak self] in self?.getNavView() }) + listenerCoordinator = CarPlayNavigationListenerCoordinator(getNavView: { [weak self] in self?.getNavView() }) + buttonFactory = CarPlayButtonFactory(getNavView: { [weak self] in self?.getNavView() }) + waitingOverlayManager = CarPlayWaitingOverlayManager(carWindow: nil, mapTemplate: nil) + navigationSessionManager = CarPlayNavigationSessionManager(mapTemplate: nil, mapView: getMapView()) + + // Set delegates + stateMonitor?.delegate = self + listenerCoordinator?.delegate = self + buttonFactory?.delegate = self + } + + // MARK: - Button Updates + + private func updateTemplateButtons() { + NSLog("πŸ”· [CarPlay] updateTemplateButtons() called") + + // Don't update buttons if waiting overlay is shown + if waitingOverlayManager?.isShown == true { + NSLog("πŸ”· [CarPlay] updateTemplateButtons() - skipping, overlay is shown") + return + } + + guard let navView = getNavView(), + let mapView = navView.view() as? GMSMapView, + let navigator = mapView.navigator, + let template = mapTemplate else { + NSLog("πŸ”· [CarPlay] updateTemplateButtons() - not ready") + return + } + + let isGuidanceActive = navigator.isGuidanceActive + NSLog("πŸ”· [CarPlay] updateTemplateButtons() - isGuidanceActive: \(isGuidanceActive)") + + buttonFactory?.updateTemplateButtons(template, isGuidanceActive: isGuidanceActive) + } + + // MARK: - Scene Lifecycle + + override func templateApplicationScene( + _ templateApplicationScene: CPTemplateApplicationScene, + didConnect interfaceController: CPInterfaceController, + to window: CPWindow + ) { + NSLog("🟒 [CarPlay] templateApplicationScene didConnect") + NSLog("πŸš—πŸ“Š [CarPlay] Instrument cluster will be used automatically if vehicle supports it") + + // Call super first - this will call getTemplate() and initialize managers + super.templateApplicationScene(templateApplicationScene, didConnect: interfaceController, to: window) + + // Now update references in managers (now that everything is initialized) + waitingOverlayManager?.updateReferences(carWindow: window, mapTemplate: mapTemplate) + navigationSessionManager?.updateReferences(mapTemplate: mapTemplate, mapView: getMapView()) + + NSLog("🟒 [CarPlay] Updated references in managers - window: \(window), mapView: \(getMapView() != nil)") + } + + override func sceneDidBecomeActive(_ scene: UIScene) { + super.sceneDidBecomeActive(scene) + NSLog("🟣 [CarPlay] sceneDidBecomeActive() called") + + NSLog("🟣 [CarPlay] sceneDidBecomeActive() - scheduling setup in 1.0s") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + NSLog("🟣 [CarPlay] sceneDidBecomeActive() asyncAfter - executing setup") + self?.setupNavigationIfNeeded() + } + } + + override func templateApplicationScene( + _ templateApplicationScene: CPTemplateApplicationScene, + didDisconnect interfaceController: CPInterfaceController, + from window: CPWindow + ) { + NSLog("πŸ”΄ [CarPlay] templateApplicationScene didDisconnect - cleaning up") + + navigationSessionManager?.cleanup() + waitingOverlayManager?.cleanup() + stateMonitor?.cleanup() + listenerCoordinator?.cleanup() + buttonFactory?.cleanup() + + mapTemplate = nil + + super.templateApplicationScene(templateApplicationScene, didDisconnect: interfaceController, from: window) + } + + // MARK: - Setup + + private func setupNavigationIfNeeded() { + NSLog("🟠 [CarPlay] setupNavigationIfNeeded() called") + + guard stateMonitor?.areViewsAvailable() == true else { + NSLog("🟠 [CarPlay] setupNavigationIfNeeded() - views not ready, starting basic monitoring") + stateMonitor?.startBasicMonitoring() + return + } + + NSLog("🟠 [CarPlay] setupNavigationIfNeeded() - views ready, attaching listener") + attemptAttachListeners() + } + + // MARK: - Listener Management + + private func attemptAttachListeners() { + NSLog("πŸ”Ά [CarPlay] attemptAttachListeners() called") + + guard listenerCoordinator?.attachListener() == true else { + NSLog("πŸ”Ά [CarPlay] attemptAttachListeners() - failed to attach") + return + } + + // Now that listener is attached (meaning navigator is available), + // try to attach the navigation session to CarPlay map view + // setupNavigatorListener() + + stateMonitor?.checkNavigationReady() + + // Show waiting overlay ONLY if no route is available at all + if hasNoRoute() { + waitingOverlayManager?.show() + } + + updateTemplateButtons() + stateMonitor?.startFullStateMonitoring() + } + + // MARK: - Helper Methods + + private func hasNoRoute() -> Bool { + guard let mapView = getMapView(), + let navigator = mapView.navigator else { + return true // No navigator = no route + } + + let hasRoute = navigator.currentRouteLeg != nil + NSLog("πŸ” [CarPlay] hasNoRoute() - currentRouteLeg: \(hasRoute ? "YES" : "NO")") + return !hasRoute + } + + + // MARK: - CPMapTemplateDelegate + + /// Enable navigation metadata support for instrument cluster (iOS 17.4+) + @available(iOS 17.4, *) + override func mapTemplateShouldProvideNavigationMetadata(_ mapTemplate: CPMapTemplate) -> Bool { + NSLog("πŸš—πŸ“Š [CarPlay] mapTemplateShouldProvideNavigationMetadata - returning TRUE (instrument cluster enabled)") + return true + } + + /// Override base: for the lane guidance (second) maneuver return .symbolOnly so CarPlay shows only the symbolSet image. + /// Must use same selector as base: displayStyleFor (not maneuverDisplayStyleFor). + override func mapTemplate(_ mapTemplate: CPMapTemplate, displayStyleFor maneuver: CPManeuver) -> CPManeuverDisplayStyle { + if navigationSessionManager?.isLaneGuidanceManeuver(maneuver) == true { + return .symbolOnly + } + return super.mapTemplate(mapTemplate, displayStyleFor: maneuver) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } +} + +// MARK: - CarPlayNavigationStateMonitorDelegate + +extension CarSceneDelegate: CarPlayNavigationStateMonitorDelegate { + func navigationStateDidChange(isReady: Bool) { + NSLog("⏱️ [CarPlay] navigationStateDidChange() - isReady: \(isReady)") + + if isReady { + // Hide waiting overlay first (this will re-enable auto-hide) + waitingOverlayManager?.hide() + + // Ensure auto-hide is enabled when no overlay + if waitingOverlayManager?.isShown == false { + mapTemplate?.automaticallyHidesNavigationBar = true + } + + // Reset and re-attach navigator listener in BaseCarSceneDelegate + // This is important after stopping/starting navigation as the navigator may have been reinitialized + // resetNavigatorListener() + + listenerCoordinator?.reattachListenerAfterDelay() + + // Update buttons after overlay is hidden + updateTemplateButtons() + } else { + // Show waiting overlay ONLY if no route is available + if hasNoRoute() { + waitingOverlayManager?.show() + } else { + // No overlay but not ready - ensure auto-hide is enabled + mapTemplate?.automaticallyHidesNavigationBar = true + } + + navigationSessionManager?.stopSession() + // Don't update buttons when overlay is shown (will be skipped anyway) + } + } + + func guidanceStateDidChange(isActive: Bool) { + NSLog("⏱️ [CarPlay] guidanceStateDidChange() - isActive: \(isActive)") + + if isActive { + // Update references BEFORE starting session (to ensure mapView is available) + navigationSessionManager?.updateReferences(mapTemplate: mapTemplate, mapView: getMapView()) + + // Start native CarPlay navigation session with callback to get cached navInfo + navigationSessionManager?.startSession(getCachedNavInfo: { [weak self] in + return self?.listenerCoordinator?.getCachedNavInfo() + }) + + NSLog("⏱️ [CarPlay] guidanceStateDidChange() - switching to follow mode") + buttonFactory?.resetViewState() + getNavView()?.followMyLocation( + perspective: GMSNavigationCameraPerspective.tilted, + zoomLevel: nil + ) + + // Force an immediate navInfo update using cached data if available + // This ensures maneuvers are displayed right away when CarPlay connects + // after navigation has already started + if let cachedNavInfo = listenerCoordinator?.getCachedNavInfo() { + NSLog("⏱️ [CarPlay] guidanceStateDidChange() - forcing initial navInfo update with cached data") + navigationSessionManager?.updateNavigation( + distanceToFinal: cachedNavInfo.distanceToFinalDestinationMeters, + timeToFinal: TimeInterval(cachedNavInfo.timeToFinalDestinationSeconds), + navInfo: cachedNavInfo + ) + } else { + NSLog("⏱️ [CarPlay] guidanceStateDidChange() - no cached navInfo available, will wait for next update") + } + + sendCustomNavigationAutoEvent( + event: "CarPlayGuidanceStarted", + data: [:] + ) + } else { + // Stop native CarPlay navigation session + navigationSessionManager?.stopSession() + + sendCustomNavigationAutoEvent( + event: "CarPlayGuidanceStopped", + data: [:] + ) + } + + updateTemplateButtons() + } + + func viewsDidBecomeAvailable() { + NSLog("🟀 [CarPlay] viewsDidBecomeAvailable() - retrying full setup") + setupNavigationIfNeeded() + } +} + +// MARK: - CarPlayNavigationListenerCoordinatorDelegate + +extension CarSceneDelegate: CarPlayNavigationListenerCoordinatorDelegate { + func navigatorDidUpdateRemainingTime(_ time: TimeInterval) { + // Time is updated via navigatorDidUpdateNavInfo + } + + func navigatorDidUpdateRemainingDistance(_ distance: CLLocationDistance) { + // Distance is updated via navigatorDidUpdateNavInfo + } + + func navigatorDidUpdateNavInfo( + _ navInfo: GMSNavigationNavInfo, nextManeuver: GMSNavigationManeuver? + ) { + NSLog("πŸš— [CarPlay] πŸ“‘ Received navInfo update - distance:\(navInfo.distanceToFinalDestinationMeters)m time:\(navInfo.timeToFinalDestinationSeconds)s") + + // Update native CarPlay display + navigationSessionManager?.updateNavigation( + distanceToFinal: navInfo.distanceToFinalDestinationMeters, + timeToFinal: TimeInterval(navInfo.timeToFinalDestinationSeconds), + navInfo: navInfo + ) + } + + func navigatorDidChangeRoute() { + NSLog("πŸ—ΊοΈ [CarPlay] navigatorDidChangeRoute() called") + + stateMonitor?.checkNavigationReady() + NSLog("πŸ—ΊοΈ [CarPlay] navigatorDidChangeRoute() - isNavigationReady: \(stateMonitor?.isNavigationReady ?? false)") + + buttonFactory?.resetViewState() + + if stateMonitor?.isNavigationReady == true { + waitingOverlayManager?.hide() + + // Ensure auto-hide is enabled when no overlay + if waitingOverlayManager?.isShown == false { + mapTemplate?.automaticallyHidesNavigationBar = true + } + + updateTemplateButtons() + } else { + // Show waiting overlay ONLY if no route is available + if hasNoRoute() { + waitingOverlayManager?.show() + } else { + // Route exists but not ready yet, just hide overlay + waitingOverlayManager?.hide() + + // Ensure auto-hide is enabled + mapTemplate?.automaticallyHidesNavigationBar = true + } + navigationSessionManager?.stopSession() + updateTemplateButtons() + } + + listenerCoordinator?.reattachListenerAfterDelay() + } +} + +// MARK: - CarPlayButtonFactoryDelegate + +extension CarSceneDelegate: CarPlayButtonFactoryDelegate { + func buttonFactoryDidRequestStartStop(isActive: Bool) { + NSLog("πŸ”· [CarPlay] buttonFactoryDidRequestStartStop() - isActive: \(isActive)") + + sendCustomNavigationAutoEvent( + event: isActive ? "AutoEventStart" : "AutoEventStop", + data: [:] + ) + } + + func buttonFactoryDidRequestViewToggle(isShowingOverview: Bool) { + NSLog("πŸ”· [CarPlay] buttonFactoryDidRequestViewToggle() - isShowingOverview: \(isShowingOverview)") + + let event = isShowingOverview ? "show_itinerary_button_pressed" : "recenter_button_pressed" + let data = ["timestamp": String(Date().timeIntervalSince1970)] + sendCustomNavigationAutoEvent(event: event, data: data) + + updateTemplateButtons() + } } diff --git a/example/lib/pages/navigation.dart b/example/lib/pages/navigation.dart index 7210a871..4057b27c 100644 --- a/example/lib/pages/navigation.dart +++ b/example/lib/pages/navigation.dart @@ -201,7 +201,13 @@ class _NavigationPageState extends ExamplePageState { } _autoViewController.listenForCustomNavigationAutoEvents((event) { - _showMessage("Received event: ${event.event}"); + //_showMessage("Received event: ${event.event}"); + + if (event.event == "AutoEventStart") { + _startGuidedNavigation(); + } else if (event.event == "AutoEventStop") { + _stopGuidedNavigation(); + } }); _isAutoScreenAvailable = await _autoViewController.isAutoScreenAvailable(); @@ -666,6 +672,33 @@ class _NavigationPageState extends ExamplePageState { Future _stopGuidedNavigation() async { assert(_navigationViewController != null); + await _stopGuidance(); + + // Reset navigation perspective to top down north up. + await _navigationViewController!.followMyLocation( + CameraPerspective.topDownNorthUp, + ); + + // Disable navigation UI after small delay to make sure routes are cleared. + // On Android routes are not always created on the map, if navigation UI is + // disabled right after cleanup. + unawaited( + Future.delayed( + const Duration(milliseconds: _disableNavigationUIDelay), + () async { + await _navigationViewController!.setNavigationUIEnabled(false); + }, + ), + ); + + setState(() { + _guidanceRunning = false; + }); + } + + Future _resetNavigation() async { + assert(_navigationViewController != null); + // Cleanup navigation session. // This will also clear destinations, stop simulation, stop guidance await GoogleMapsNavigator.cleanup(); @@ -935,7 +968,7 @@ class _NavigationPageState extends ExamplePageState { // If there is no next waypoint, it means we have arrived at the final // destination. Stop navigation completely. - await _stopGuidedNavigation(); + await _resetNavigation(); _showMessage('You have arrived at your final destination!'); } else { @@ -999,7 +1032,7 @@ class _NavigationPageState extends ExamplePageState { Future _clearNavigationWaypoints() async { // Stopping guided navigation will also clear the waypoints. - await _stopGuidedNavigation(); + await _resetNavigation(); setState(() { _waypoints.clear(); }); @@ -1409,6 +1442,11 @@ class _NavigationPageState extends ExamplePageState { onPressed: _validRoute ? _stopGuidedNavigation : null, child: const Text('Stop Guidance'), ), + if (_validRoute) + ElevatedButton( + onPressed: () => _resetNavigation(), + child: const Text('Reset navigation'), + ), if (_guidanceRunning && _simulationState == SimulationState.notRunning) ElevatedButton( @@ -1674,6 +1712,13 @@ class _NavigationPageState extends ExamplePageState { : null, child: const Text('Stop navigation'), ), + ElevatedButton( + onPressed: + _navigatorInitialized + ? () => _resetNavigation() + : null, + child: const Text('Reset navigation'), + ), ElevatedButton( onPressed: _navigatorInitialized diff --git a/example/lib/pages/turn_by_turn.dart b/example/lib/pages/turn_by_turn.dart index ddb32f33..67d5e3a4 100644 --- a/example/lib/pages/turn_by_turn.dart +++ b/example/lib/pages/turn_by_turn.dart @@ -46,6 +46,29 @@ class _TurnByTurnPageState extends ExamplePageState { NavInfo? _navInfo; + final GoogleMapsAutoViewController _autoViewController = + GoogleMapsAutoViewController(); + + @override + void initState() { + super.initState(); + unawaited(_initialize()); + } + + + + Future _initialize() async { + _autoViewController.listenForCustomNavigationAutoEvents((event) { + //_showMessage("Received event: ${event.event}"); + + if (event.event == "AutoEventStart") { + _startNavigation(); + } else if (event.event == "AutoEventStop") { + _stopNavigation(); + } + }); + } + // ignore: use_setters_to_change_properties void _onViewCreated(GoogleNavigationViewController controller) async { _navigationViewController = controller; diff --git a/ios/google_navigation_flutter/Sources/google_navigation_flutter/BaseCarSceneDelegate.swift b/ios/google_navigation_flutter/Sources/google_navigation_flutter/BaseCarSceneDelegate.swift index 577ea77a..2a97d6bb 100644 --- a/ios/google_navigation_flutter/Sources/google_navigation_flutter/BaseCarSceneDelegate.swift +++ b/ios/google_navigation_flutter/Sources/google_navigation_flutter/BaseCarSceneDelegate.swift @@ -15,6 +15,7 @@ import CarPlay import Foundation import GoogleMaps +import GoogleNavigation open class BaseCarSceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPMapTemplateDelegate @@ -32,7 +33,14 @@ open class BaseCarSceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate navView } - public func templateApplicationScene( + public func getMapView() -> GMSMapView? { + if navView == nil { + NSLog("[CarPlay] πŸ—ΊοΈ BaseCarSceneDelegate getMapView navView is null") + } + return navView?.getMapView() + } + + open func templateApplicationScene( _ templateApplicationScene: CPTemplateApplicationScene, didConnect interfaceController: CPInterfaceController, to window: CPWindow @@ -90,11 +98,11 @@ open class BaseCarSceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate mapConfiguration: MapConfiguration( cameraPosition: nil, mapType: .normal, - compassEnabled: true, + compassEnabled: false, rotateGesturesEnabled: false, - scrollGesturesEnabled: true, + scrollGesturesEnabled: false, tiltGesturesEnabled: false, - zoomGesturesEnabled: true, + zoomGesturesEnabled: false, scrollGesturesEnabledDuringRotateOrZoom: false, mapColorScheme: .unspecified ), @@ -117,7 +125,8 @@ open class BaseCarSceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate } } - // CPMapTemplateDelegate + // MARK: - CPMapTemplateDelegate + open func mapTemplate( _ mapTemplate: CPMapTemplate, panWith direction: CPMapTemplate.PanDirection @@ -126,6 +135,22 @@ open class BaseCarSceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate navView?.animateCameraByScroll(dx: scrollAmount.x, dy: scrollAmount.y) } + // MARK: - CPMapTemplateDelegate Optional Methods + + @objc open func mapTemplate( + _ mapTemplate: CPMapTemplate, + displayStyleFor maneuver: CPManeuver + ) -> CPManeuverDisplayStyle { + NSLog("[CarPlay] πŸ—ΊοΈ BaseCarSceneDelegate: displayStyleFor maneuver called") + return [] + } + + @available(iOS 17.4, *) + @objc open func mapTemplateShouldProvideNavigationMetadata(_ mapTemplate: CPMapTemplate) -> Bool { + NSLog("[CarPlay] πŸ—ΊοΈ BaseCarSceneDelegate: mapTemplateShouldProvideNavigationMetadata called") + return false + } + func scrollAmount(for direction: CPMapTemplate.PanDirection) -> CGPoint { let scrollDistance: CGFloat = 80.0 var scrollAmount = CGPoint(x: 0.0, y: 0.0) diff --git a/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationSessionManager.swift b/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationSessionManager.swift index d0e1035d..5e5c2701 100644 --- a/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationSessionManager.swift +++ b/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationSessionManager.swift @@ -43,6 +43,10 @@ public class ExposedGoogleMapsNavigator: NSObject { public static func enableRoadSnappedLocationUpdates() { GoogleMapsNavigationSessionManager.shared.enableRoadSnappedLocationUpdates() } + + public static func getSession() throws -> GMSNavigationSession { + try GoogleMapsNavigationSessionManager.shared.getSession() + } } class GoogleMapsNavigationSessionManager: NSObject { diff --git a/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationView.swift b/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationView.swift index 809c3bd5..96254459 100644 --- a/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationView.swift +++ b/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationView.swift @@ -59,6 +59,10 @@ public class GoogleMapsNavigationView: NSObject, FlutterPlatformView, ViewSettle _mapView } + public func getMapView() -> GMSMapView { + _mapView + } + // Getter that wont return viewEventApi if viewId is missing. private func getViewEventApi() -> ViewEventApi? { if _viewId != nil {