Skip to content

[Feature request]: Provide CarPlay support equivalent to Android Auto (NavigationTemplate) #594

@bderrien

Description

@bderrien

Is there an existing issue for this?

  • I have searched the existing issues

Use case

Hi, with Android Auto and NavigationTemplateBuilder, I can easily display everything I want, but with CarPlay, It’s not simple.

I’d like to provide a good CarPlay experience with:

  • step-by-step instructions (maneuvers, descriptions, next step)
  • navigation summary (estimated time, duration, distance)
  • the map itinerary focused on the right, leaving the left side for the information overlay
  • map clickable (pin, alternative itinerary, drag) ?
  • ...

I’m trying to do these things, but I’m not sure if I’m taking the right approach

Proposal

For now, I do the following:

  • A template to inform users that CarPlay needs to be initialized in the app
  • Hide the compass button
  • A navigation bar with Start / Stop buttons (to start or stop guidance) and a Recenter button
  • Left-side overlays (displayed only during guidance):
    • Step-by-step instructions (including the next maneuver)
    • Summary information

But I can’t do the following:

  • Setting padding on the map only applies to the Google logo, not to the user position or the route polyline
  • The map is not clickable (for example, to manage alternative routes)
Image Image Image

Here is my code, do you think this is the right approach? (two files)

CarSceneDelegate

import CarPlay
import GoogleNavigation
import UIKit
import google_navigation_flutter

class CarSceneDelegate: BaseCarSceneDelegate, GMSNavigatorListener {
    private var customLayout: CustomCarPlayLayout?
    private var routeListenerAdded = false
    private var navigatorStateCheckTimer: Timer?
    private var lastGuidanceState: Bool = false
    private weak var currentTemplate: CPMapTemplate?
    private var waitingTemplate: CPInformationTemplate?
    private var mapTemplateForRestore: CPMapTemplate?
    private var cachedRemainingTime: TimeInterval?
    private var cachedRemainingDistance: CLLocationDistance?
    private var isNavigationReady: Bool = false
    private weak var carPlayScene: CPTemplateApplicationScene?
    
    private func getInterfaceController() -> CPInterfaceController? {
        return carPlayScene?.interfaceController
    }
    
    override func getTemplate() -> CPMapTemplate {
        let template = CPMapTemplate()
        template.dismissPanningInterface(animated: false)
        template.automaticallyHidesNavigationBar = true
        currentTemplate = template
        
        checkNavigationReady()
        
        if isNavigationReady {
            updateTemplateButtons()
        } else {
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
                self?.showWaitingInformationTemplate()
            }
        }
        
        return template
    }
    
    private func showWaitingInformationTemplate() {
        guard let interfaceController = getInterfaceController() else {
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
                self?.showWaitingInformationTemplate()
            }
            return
        }
        
        if waitingTemplate != nil || isNavigationReady {
            return
        }
        
        let informationItem = CPInformationItem(
            title: "En attente",
            detail: "En attente de la session de navigation..."
        )
        
        waitingTemplate = CPInformationTemplate(
            title: "Navigation",
            layout: .leading,
            items: [informationItem],
            actions: []
        )
        
        if let waitingTemplate = waitingTemplate, let currentTemplate = currentTemplate {
            mapTemplateForRestore = currentTemplate
            
            interfaceController.setRootTemplate(waitingTemplate, animated: true) { [weak self] success, error in
                if let error = error {
                    // Error handled silently
                }
            }
        }
    }
    
    private func switchToMapTemplate() {
        guard let interfaceController = getInterfaceController() else {
            return
        }
        
        if waitingTemplate != nil {
            let mapTemplate = mapTemplateForRestore ?? {
                let template = CPMapTemplate()
                template.dismissPanningInterface(animated: false)
                template.automaticallyHidesNavigationBar = true
                return template
            }()
            
            currentTemplate = mapTemplate
            mapTemplateForRestore = nil
            
            interfaceController.setRootTemplate(mapTemplate, animated: true) { [weak self] success, error in
                if let error = error {
                    // Error handled silently
                } else {
                    self?.waitingTemplate = nil
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                        self?.updateTemplateButtons()
                    }
                }
            }
        }
    }
    
    private func checkNavigationReady() {
        guard let navView = getNavView(),
              let mapView = navView.view() as? GMSMapView,
              let navigator = mapView.navigator else {
            isNavigationReady = false
            return
        }
        
        isNavigationReady = navigator.currentRouteLeg != nil
    }
    
    private func updateTemplateButtons() {
        guard let template = currentTemplate,
              let navView = getNavView(),
              let mapView = navView.view() as? GMSMapView,
              let navigator = mapView.navigator,
              navigator.currentRouteLeg != nil else {
            showWaitingInformationTemplate()
            return
        }
        
        switchToMapTemplate()
        
        let isGuidanceActive = navigator.isGuidanceActive
        
        if isGuidanceActive {
            customLayout?.showOverlays()
        } else {
            customLayout?.hideOverlays()
        }
        
        let startOrQuitButton = CPBarButton(title: isGuidanceActive ? "Stop" : "Start") { [weak self] _ in
            guard let self = self,
                  let mapView = self.getNavView()?.view() as? GMSMapView,
                  let navigator = mapView.navigator else {
                return
            }
            
            let currentState = navigator.isGuidanceActive
            navigator.isGuidanceActive = !currentState
            
            self.sendCustomNavigationAutoEvent(
                event: currentState ? "CarPlayEventStop" : "CarPlayEventStart",
                data: [:]
            )
        }
        
        let recenterButton = CPBarButton(title: "Re-center") { [weak self] _ in
            let data = ["timestamp": String(Date().timeIntervalSince1970)]
            self?.sendCustomNavigationAutoEvent(event: "recenter_button_pressed", data: data)
            self?.getNavView()?.followMyLocation(
                perspective: GMSNavigationCameraPerspective.tilted,
                zoomLevel: nil
            )
        }
        
        template.leadingNavigationBarButtons = [startOrQuitButton, recenterButton]
    }
    
    override func sceneDidBecomeActive(_ scene: UIScene) {
        super.sceneDidBecomeActive(scene)
        
        if let carPlayScene = scene as? CPTemplateApplicationScene {
            self.carPlayScene = carPlayScene
        }
        
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
            self?.setupCustomOverlaysIfNeeded()
        }
    }
    
    override func templateApplicationScene(
        _ templateApplicationScene: CPTemplateApplicationScene,
        didDisconnect interfaceController: CPInterfaceController,
        from window: CPWindow
    ) {
        navigatorStateCheckTimer?.invalidate()
        navigatorStateCheckTimer = nil
        customLayout?.removeFromSuperview()
        customLayout = nil
        routeListenerAdded = false
        isNavigationReady = false
        waitingTemplate = nil
        mapTemplateForRestore = nil
        carPlayScene = nil
        super.templateApplicationScene(templateApplicationScene, didDisconnect: interfaceController, from: window)
    }
    
    // MARK: - Custom Overlay Setup
    
    private func setupCustomOverlaysIfNeeded() {
        if customLayout != nil {
            return
        }
        
        guard let navView = getNavView(),
              let mapView = navView.view() as? GMSMapView,
              let parentView = mapView.superview else {
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
                self?.setupCustomOverlaysIfNeeded()
            }
            return
        }
        
        mapView.settings.compassButton = false
        
        customLayout = CustomCarPlayLayout(navView: navView, frame: parentView.bounds)
        customLayout?.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        parentView.addSubview(customLayout!)
        parentView.bringSubviewToFront(customLayout!)
        customLayout?.hideOverlays()
        
        attemptAttachListeners()
    }
    
    // MARK: - Listener Attachment
    
    private func attemptAttachListeners() {
        if routeListenerAdded {
            return
        }
        
        guard let navView = getNavView() else {
            DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
                self?.attemptAttachListeners()
            }
            return
        }
        
        guard let mapView = navView.view() as? GMSMapView else {
            DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
                self?.attemptAttachListeners()
            }
            return
        }
        
        guard let navigator = mapView.navigator else {
            DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
                self?.attemptAttachListeners()
            }
            return
        }
        
        navigator.remove(self)
        navigator.add(self)
        routeListenerAdded = true
        lastGuidanceState = navigator.isGuidanceActive
        
        checkNavigationReady()
        if isNavigationReady {
            updateTemplateButtons()
        }
        
        startNavigatorStateCheck()
    }
    
    private func startNavigatorStateCheck() {
        navigatorStateCheckTimer?.invalidate()
        
        navigatorStateCheckTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] timer in
            guard let self = self else {
                timer.invalidate()
                return
            }
            
            guard let mapView = self.getNavView()?.view() as? GMSMapView,
                  let navigator = mapView.navigator else {
                return
            }
            
            let wasReady = self.isNavigationReady
            self.checkNavigationReady()
            
            if !wasReady && self.isNavigationReady {
                self.switchToMapTemplate()
                navigator.remove(self)
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
                    guard let self = self,
                          let mapView = self.getNavView()?.view() as? GMSMapView,
                          let navigator = mapView.navigator else { return }
                    navigator.add(self)
                }
                self.updateTemplateButtons()
            }
            
            let currentState = navigator.isGuidanceActive
            if self.lastGuidanceState != currentState {
                self.lastGuidanceState = currentState
                
                if currentState {
                    navigator.remove(self)
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
                        guard let self = self,
                              let mapView = self.getNavView()?.view() as? GMSMapView,
                              let navigator = mapView.navigator else { return }
                        navigator.add(self)
                    }
                }
                
                self.updateTemplateButtons()
            }
        }
    }
    
    // MARK: - GMSNavigatorListener
    
    func navigator(_ navigator: GMSNavigator, didUpdateRemainingTime remainingTime: TimeInterval) {
        cachedRemainingTime = remainingTime
        customLayout?.updateRemainingInfo(
            remainingTime: cachedRemainingTime,
            remainingDistance: cachedRemainingDistance
        )
    }
    
    func navigator(_ navigator: GMSNavigator, didUpdateRemainingDistance remainingDistance: CLLocationDistance) {
        cachedRemainingDistance = remainingDistance
        customLayout?.updateRemainingInfo(
            remainingTime: cachedRemainingTime,
            remainingDistance: cachedRemainingDistance
        )
    }
    
    func navigator(_ navigator: GMSNavigator, didUpdate navInfo: GMSNavigationNavInfo) {
        if navigator.isGuidanceActive {
            let nextManeuver = navInfo.remainingSteps.first?.maneuver
            
            customLayout?.updateNavigationInfo(navInfo, nextManeuver: nextManeuver)
        }
    }
    
    func navigatorDidChangeRoute(_ navigator: GMSNavigator) {
        navigator.remove(self)
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
            guard let self = self,
                  let mapView = self.getNavView()?.view() as? GMSMapView,
                  let navigator = mapView.navigator else { return }
            navigator.add(self)
        }
        
        checkNavigationReady()
        if isNavigationReady {
            switchToMapTemplate()
            updateTemplateButtons()
        } else {
            showWaitingInformationTemplate()
        }
    }
}

CustomCarPlayLayout

import CarPlay
import GoogleNavigation
import UIKit
import google_navigation_flutter

/// Overlay view for CarPlay navigation with custom UI elements
class CustomCarPlayLayout: UIView {
    // MARK: - Properties
    
    private(set) weak var navView: GoogleMapsNavigationView?
    
    private(set) var topOverlayView: UIView!
    private(set) var bottomOverlayView: UIView!
    private(set) var maneuverView: CustomManeuverView!
    private(set) var nextStepPreviewView: UIView!
    private(set) var nextStepLabel: UILabel!
    private(set) var nextStepIconView: UIImageView!
    private(set) var etaView: CustomETAView!
    private(set) var waitingMessageView: UIView!
    private(set) var waitingMessageLabel: UILabel!
    
    // Size multiplier based on screen size (smaller screens = smaller UI)
    private var sizeMultiplier: CGFloat {
        let screenHeight = bounds.height
        // CarPlay screens typically range from ~320 to ~800 points in height
        // Base size for ~480pt height, scale up/down from there
        return min(max(screenHeight / 480.0, 0.7), 1.3)
    }
    
    private var currentInstruction: String = ""
    private var currentDistance: String = ""
    private var currentManeuver: GMSNavigationManeuver = .unknown
    
    // MARK: - Initialization
    
    init(navView: GoogleMapsNavigationView, frame: CGRect) {
        super.init(frame: frame)
        self.navView = navView
        setupLayout()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // MARK: - Layout Setup
    
    private func setupLayout() {
        backgroundColor = .clear
        isUserInteractionEnabled = false
        
        setupTopOverlay()
        setupBottomOverlay()
        setupWaitingMessage()
    }
    
    private func setupTopOverlay() {
        topOverlayView = UIView()
        topOverlayView.backgroundColor = UIColor.black.withAlphaComponent(0.85)
        topOverlayView.translatesAutoresizingMaskIntoConstraints = false
        topOverlayView.layer.cornerRadius = 12
        topOverlayView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner]
        topOverlayView.layer.borderWidth = 1
        topOverlayView.layer.borderColor = UIColor.white.withAlphaComponent(0.3).cgColor
        topOverlayView.clipsToBounds = true
        addSubview(topOverlayView)
        
        maneuverView = CustomManeuverView(sizeMultiplier: sizeMultiplier)
        maneuverView.translatesAutoresizingMaskIntoConstraints = false
        topOverlayView.addSubview(maneuverView)
        
        // Next step preview view - separate from current step
        nextStepPreviewView = UIView()
        nextStepPreviewView.backgroundColor = UIColor.black.withAlphaComponent(0.85)
        nextStepPreviewView.translatesAutoresizingMaskIntoConstraints = false
        nextStepPreviewView.layer.cornerRadius = 12
        nextStepPreviewView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner]
        nextStepPreviewView.layer.borderWidth = 1
        nextStepPreviewView.layer.borderColor = UIColor.white.withAlphaComponent(0.3).cgColor
        nextStepPreviewView.clipsToBounds = true
        addSubview(nextStepPreviewView)
        
        nextStepLabel = UILabel()
        nextStepLabel.font = .systemFont(ofSize: 14 * sizeMultiplier, weight: .regular)
        nextStepLabel.textColor = .white
        nextStepLabel.numberOfLines = 1
        nextStepLabel.lineBreakMode = .byTruncatingTail
        nextStepLabel.translatesAutoresizingMaskIntoConstraints = false
        nextStepPreviewView.addSubview(nextStepLabel)
        
        nextStepIconView = UIImageView()
        nextStepIconView.contentMode = .scaleAspectFit
        nextStepIconView.tintColor = .white
        nextStepIconView.translatesAutoresizingMaskIntoConstraints = false
        nextStepPreviewView.addSubview(nextStepIconView)
        
        let topHeight: CGFloat = 80 * sizeMultiplier
        let padding: CGFloat = 8 * sizeMultiplier
        let marginLeft: CGFloat = 10 * sizeMultiplier
        let nextStepHeight: CGFloat = 32 * sizeMultiplier
        let spacing: CGFloat = 4 * sizeMultiplier
        
        NSLayoutConstraint.activate([
            topOverlayView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 8),
            topOverlayView.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: marginLeft),
            topOverlayView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.45),
            topOverlayView.heightAnchor.constraint(equalToConstant: topHeight),
            
            maneuverView.topAnchor.constraint(equalTo: topOverlayView.topAnchor, constant: padding),
            maneuverView.leadingAnchor.constraint(equalTo: topOverlayView.leadingAnchor, constant: padding),
            maneuverView.trailingAnchor.constraint(equalTo: topOverlayView.trailingAnchor, constant: -padding),
            maneuverView.bottomAnchor.constraint(equalTo: topOverlayView.bottomAnchor, constant: -padding),
            
            nextStepPreviewView.topAnchor.constraint(equalTo: topOverlayView.bottomAnchor, constant: spacing),
            nextStepPreviewView.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: marginLeft),
            nextStepPreviewView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.45),
            nextStepPreviewView.heightAnchor.constraint(equalToConstant: nextStepHeight),
            
            nextStepIconView.leadingAnchor.constraint(equalTo: nextStepPreviewView.leadingAnchor, constant: padding),
            nextStepIconView.centerYAnchor.constraint(equalTo: nextStepPreviewView.centerYAnchor),
            nextStepIconView.widthAnchor.constraint(equalToConstant: 24 * sizeMultiplier),
            nextStepIconView.heightAnchor.constraint(equalToConstant: 24 * sizeMultiplier),
            
            nextStepLabel.leadingAnchor.constraint(equalTo: nextStepIconView.trailingAnchor, constant: 8 * sizeMultiplier),
            nextStepLabel.centerYAnchor.constraint(equalTo: nextStepPreviewView.centerYAnchor),
            nextStepLabel.trailingAnchor.constraint(lessThanOrEqualTo: nextStepPreviewView.trailingAnchor, constant: -padding)
        ])
        
        maneuverView.update(
            roadName: "",
            instruction: "En attente...",
            maneuver: .unknown
        )
        
        topOverlayView.isHidden = true
        nextStepPreviewView.isHidden = true
    }
    
    private func setupBottomOverlay() {
        bottomOverlayView = UIView()
        bottomOverlayView.backgroundColor = UIColor.black.withAlphaComponent(0.85)
        bottomOverlayView.translatesAutoresizingMaskIntoConstraints = false
        bottomOverlayView.layer.cornerRadius = 8
        bottomOverlayView.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
        bottomOverlayView.layer.borderWidth = 1
        bottomOverlayView.layer.borderColor = UIColor.white.withAlphaComponent(0.3).cgColor
        bottomOverlayView.clipsToBounds = true
        addSubview(bottomOverlayView)
        
        etaView = CustomETAView(sizeMultiplier: sizeMultiplier, isVertical: true)
        etaView.translatesAutoresizingMaskIntoConstraints = false
        bottomOverlayView.addSubview(etaView)
        
        let bottomHeight: CGFloat = 40 * sizeMultiplier
        let padding: CGFloat = 3 * sizeMultiplier
        let horizontalPadding: CGFloat = 8 * sizeMultiplier
        let marginLeft: CGFloat = 10 * sizeMultiplier
        
        NSLayoutConstraint.activate([
            bottomOverlayView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -8),
            bottomOverlayView.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: marginLeft),
            bottomOverlayView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.45),
            bottomOverlayView.heightAnchor.constraint(equalToConstant: bottomHeight),
            
            etaView.topAnchor.constraint(equalTo: bottomOverlayView.topAnchor, constant: padding),
            etaView.leadingAnchor.constraint(equalTo: bottomOverlayView.leadingAnchor, constant: horizontalPadding),
            etaView.trailingAnchor.constraint(equalTo: bottomOverlayView.trailingAnchor, constant: -horizontalPadding),
            etaView.bottomAnchor.constraint(equalTo: bottomOverlayView.bottomAnchor, constant: 0)
        ])
        
        etaView.update(
            remainingTime: nil,
            remainingDistance: nil,
            eta: "--:--"
        )
        
        bottomOverlayView.isHidden = true
    }
    
    private func setupWaitingMessage() {
        waitingMessageView = UIView()
        waitingMessageView.backgroundColor = UIColor.black.withAlphaComponent(0.9)
        waitingMessageView.translatesAutoresizingMaskIntoConstraints = false
        waitingMessageView.layer.cornerRadius = 12
        waitingMessageView.layer.borderWidth = 2
        waitingMessageView.layer.borderColor = UIColor.white.withAlphaComponent(0.4).cgColor
        waitingMessageView.clipsToBounds = true
        waitingMessageView.isUserInteractionEnabled = false
        addSubview(waitingMessageView)
        bringSubviewToFront(waitingMessageView)
        
        waitingMessageLabel = UILabel()
        waitingMessageLabel.text = "En attente de la session de navigation..."
        waitingMessageLabel.font = .systemFont(ofSize: 18 * sizeMultiplier, weight: .medium)
        waitingMessageLabel.textColor = .white
        waitingMessageLabel.textAlignment = .center
        waitingMessageLabel.numberOfLines = 0
        waitingMessageLabel.translatesAutoresizingMaskIntoConstraints = false
        waitingMessageView.addSubview(waitingMessageLabel)
        
        let padding: CGFloat = 20 * sizeMultiplier
        let maxWidth: CGFloat = 300 * sizeMultiplier
        
        NSLayoutConstraint.activate([
            waitingMessageView.centerXAnchor.constraint(equalTo: safeAreaLayoutGuide.centerXAnchor),
            waitingMessageView.centerYAnchor.constraint(equalTo: safeAreaLayoutGuide.centerYAnchor),
            waitingMessageView.widthAnchor.constraint(lessThanOrEqualToConstant: maxWidth),
            waitingMessageView.leadingAnchor.constraint(greaterThanOrEqualTo: safeAreaLayoutGuide.leadingAnchor, constant: padding),
            waitingMessageView.trailingAnchor.constraint(lessThanOrEqualTo: safeAreaLayoutGuide.trailingAnchor, constant: -padding),
            
            waitingMessageLabel.topAnchor.constraint(equalTo: waitingMessageView.topAnchor, constant: padding),
            waitingMessageLabel.leadingAnchor.constraint(equalTo: waitingMessageView.leadingAnchor, constant: padding),
            waitingMessageLabel.trailingAnchor.constraint(equalTo: waitingMessageView.trailingAnchor, constant: -padding),
            waitingMessageLabel.bottomAnchor.constraint(equalTo: waitingMessageView.bottomAnchor, constant: -padding)
        ])
        
        waitingMessageView.isHidden = true
    }
    
    // MARK: - Public Methods
    
    func updateNavigationInfo(_ navInfo: GMSNavigationNavInfo, nextManeuver: GMSNavigationManeuver? = nil) {
        guard let currentStep = navInfo.currentStep else {
            hideOverlays()
            return
        }
        
        showOverlays()
        
        maneuverView.update(
            roadName: currentStep.simpleRoadName,
            instruction: currentStep.fullInstructionText,
            maneuver: currentStep.maneuver
        )
        
        if let nextStep = navInfo.remainingSteps.first {
            nextStepIconView.image = maneuverIcon(for: nextStep.maneuver)
            let nextRoadName = nextStep.simpleRoadName
            nextStepLabel.text = nextRoadName.isEmpty ? (nextStep.fullInstructionText) : nextRoadName
            nextStepPreviewView.isHidden = false
        } else {
            nextStepPreviewView.isHidden = true
        }
        
        let remainingTime = TimeInterval(navInfo.timeToFinalDestinationSeconds)
        let remainingDistance = CLLocationDistance(navInfo.distanceToFinalDestinationMeters)
        
        updateRemainingInfo(remainingTime: remainingTime, remainingDistance: remainingDistance)
    }
    
    private func maneuverIcon(for maneuver: GMSNavigationManeuver) -> UIImage? {
        let iconName: String
        switch maneuver {
        case .turnLeft, .turnSharpLeft, .turnSlightLeft:
            iconName = "arrow.turn.up.left"
        case .turnRight, .turnSharpRight, .turnSlightRight:
            iconName = "arrow.turn.up.right"
        case .turnUTurnCounterClockwise:
            iconName = "arrow.uturn.left"
        case .turnUTurnClockwise:
            iconName = "arrow.uturn.right"
        case .straight:
            iconName = "arrow.up"
        case .onRampLeft, .forkLeft:
            iconName = "arrow.up.left"
        case .onRampRight, .forkRight:
            iconName = "arrow.up.right"
        case .mergeLeft:
            iconName = "arrow.merge"
        case .mergeRight:
            iconName = "arrow.merge"
        case .destination, .destinationLeft, .destinationRight:
            iconName = "mappin.circle.fill"
        case .ferryBoat:
            iconName = "ferry.fill"
        case .ferryTrain:
            iconName = "tram.fill"
        default:
            let maneuverString = String(describing: maneuver)
            if maneuverString.lowercased().contains("roundabout") {
                iconName = "arrow.triangle.turn.up.right.circle"
            } else {
                iconName = "arrow.up"
            }
        }
        
        let iconPointSize: CGFloat = 20 * sizeMultiplier
        return UIImage(systemName: iconName)?.withConfiguration(
            UIImage.SymbolConfiguration(pointSize: iconPointSize, weight: .medium)
        )
    }
    
    func updateRemainingInfo(remainingTime: TimeInterval?, remainingDistance: CLLocationDistance?) {
        etaView.update(
            remainingTime: remainingTime,
            remainingDistance: remainingDistance,
            eta: remainingTime.map { calculateETA($0) } ?? "--:--"
        )
    }
    
    func showOverlays() {
        topOverlayView.isHidden = false
        bottomOverlayView.isHidden = false
        topOverlayView.setNeedsLayout()
        bottomOverlayView.setNeedsLayout()
        setNeedsLayout()
        layoutIfNeeded()
    }
    
    func hideOverlays() {
        topOverlayView.isHidden = true
        bottomOverlayView.isHidden = true
        nextStepPreviewView.isHidden = true
    }
    
    func showWaitingMessage(_ message: String? = nil) {
        if let message = message {
            waitingMessageLabel.text = message
        }
        hideOverlays()
        waitingMessageView.isHidden = false
        waitingMessageView.alpha = 1.0
        bringSubviewToFront(waitingMessageView)
        waitingMessageView.setNeedsLayout()
        waitingMessageView.layoutIfNeeded()
        setNeedsLayout()
        layoutIfNeeded()
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
            guard let self = self else { return }
            self.waitingMessageView.setNeedsLayout()
            self.waitingMessageView.layoutIfNeeded()
            self.setNeedsLayout()
            self.layoutIfNeeded()
        }
    }
    
    func hideWaitingMessage() {
        waitingMessageView.isHidden = true
    }
    
    // MARK: - Overlay Width Calculation
    
    /// Calcule la largeur totale occupée par les overlays (pour le padding de la map)
    func getOverlayWidth() -> CGFloat {
        let marginLeft: CGFloat = 10 * sizeMultiplier
        let overlayWidth = bounds.width * 0.45
        let totalWidth = overlayWidth + marginLeft
        
        return totalWidth
    }
    
    // MARK: - Formatting Helpers
    
    private func calculateETA(_ remainingSeconds: TimeInterval) -> String {
        let eta = Date().addingTimeInterval(remainingSeconds)
        let formatter = DateFormatter()
        formatter.dateFormat = "HH:mm"
        return formatter.string(from: eta)
    }
}

// MARK: - CustomManeuverView

class CustomManeuverView: UIView {
    private let iconImageView = UIImageView()
    private let instructionLabel = UILabel()
    private var instructionLeadingConstraint: NSLayoutConstraint!
    private var instructionLeadingFromIconConstraint: NSLayoutConstraint!
    private let sizeMultiplier: CGFloat
    
    init(sizeMultiplier: CGFloat = 1.0) {
        self.sizeMultiplier = sizeMultiplier
        super.init(frame: .zero)
        setupView()
    }
    
    required init?(coder: NSCoder) {
        self.sizeMultiplier = 1.0
        super.init(coder: coder)
        setupView()
    }
    
    private func setupView() {
        iconImageView.contentMode = .scaleAspectFit
        iconImageView.tintColor = .white
        iconImageView.translatesAutoresizingMaskIntoConstraints = false
        iconImageView.isHidden = true
        addSubview(iconImageView)
        
        let instructionFontSize: CGFloat = 18 * sizeMultiplier
        instructionLabel.font = .systemFont(ofSize: instructionFontSize, weight: .regular)
        instructionLabel.textColor = .white
        instructionLabel.numberOfLines = 2
        instructionLabel.lineBreakMode = .byTruncatingTail
        instructionLabel.translatesAutoresizingMaskIntoConstraints = false
        addSubview(instructionLabel)
        
        let iconSize: CGFloat = 24 * sizeMultiplier
        let spacing: CGFloat = 8 * sizeMultiplier
        
        NSLayoutConstraint.activate([
            iconImageView.leadingAnchor.constraint(equalTo: leadingAnchor),
            iconImageView.centerYAnchor.constraint(equalTo: centerYAnchor),
            iconImageView.widthAnchor.constraint(equalToConstant: iconSize),
            iconImageView.heightAnchor.constraint(equalToConstant: iconSize)
        ])
        
        instructionLeadingConstraint = instructionLabel.leadingAnchor.constraint(equalTo: leadingAnchor)
        instructionLeadingFromIconConstraint = instructionLabel.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: spacing)
        
        NSLayoutConstraint.activate([
            instructionLabel.topAnchor.constraint(equalTo: topAnchor),
            instructionLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
            instructionLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
            instructionLeadingConstraint
        ])
    }
    
    func update(roadName: String, instruction: String, maneuver: GMSNavigationManeuver) {
        // Show icon only if there's a real maneuver (not straight or unknown)
        let hasRealManeuver = maneuver != .straight && maneuver != .unknown
        
        if hasRealManeuver {
            iconImageView.image = maneuverIcon(for: maneuver)
            iconImageView.isHidden = false
            instructionLeadingConstraint.isActive = false
            instructionLeadingFromIconConstraint.isActive = true
        } else {
            iconImageView.isHidden = true
            instructionLeadingFromIconConstraint.isActive = false
            instructionLeadingConstraint.isActive = true
        }
        
        if !roadName.isEmpty {
            let attributedText = NSMutableAttributedString()
            
            let versAttributes: [NSAttributedString.Key: Any] = [
                .foregroundColor: UIColor.white,
                .font: UIFont.systemFont(ofSize: 16 * sizeMultiplier, weight: .regular)
            ]
            attributedText.append(NSAttributedString(string: "vers ", attributes: versAttributes))
            
            let roadNameAttributes: [NSAttributedString.Key: Any] = [
                .foregroundColor: UIColor.white,
                .font: UIFont.systemFont(ofSize: 18 * sizeMultiplier, weight: .bold)
            ]
            attributedText.append(NSAttributedString(string: roadName, attributes: roadNameAttributes))
            
            instructionLabel.attributedText = attributedText
        } else if !instruction.isEmpty {
            instructionLabel.text = instruction
        } else {
            instructionLabel.text = ""
        }
    }
    
    private func maneuverIcon(for maneuver: GMSNavigationManeuver) -> UIImage? {
        let iconName: String
        switch maneuver {
        case .turnLeft, .turnSharpLeft, .turnSlightLeft:
            iconName = "arrow.turn.up.left"
        case .turnRight, .turnSharpRight, .turnSlightRight:
            iconName = "arrow.turn.up.right"
        case .turnUTurnCounterClockwise:
            iconName = "arrow.uturn.left"
        case .turnUTurnClockwise:
            iconName = "arrow.uturn.right"
        case .straight:
            iconName = "arrow.up"
        case .onRampLeft, .forkLeft:
            iconName = "arrow.up.left"
        case .onRampRight, .forkRight:
            iconName = "arrow.up.right"
        case .mergeLeft:
            iconName = "arrow.merge"
        case .mergeRight:
            iconName = "arrow.merge"
        case .destination, .destinationLeft, .destinationRight:
            iconName = "mappin.circle.fill"
        case .ferryBoat:
            iconName = "ferry.fill"
        case .ferryTrain:
            iconName = "tram.fill"
        default:
            let maneuverString = String(describing: maneuver)
            if maneuverString.lowercased().contains("roundabout") {
                iconName = "arrow.triangle.turn.up.right.circle"
            } else {
                iconName = "arrow.up"
            }
        }
        
        let iconPointSize: CGFloat = 24 * sizeMultiplier
        return UIImage(systemName: iconName)?.withConfiguration(
            UIImage.SymbolConfiguration(pointSize: iconPointSize, weight: .medium)
        )
    }
}

// MARK: - CustomETAView

class CustomETAView: UIView {
    private let etaValueLabel = UILabel()
    private let etaTitleLabel = UILabel()
    
    private let remainingTimeValueLabel = UILabel()
    private let remainingTimeUnitLabel = UILabel()
    
    private let remainingDistanceValueLabel = UILabel()
    private let remainingDistanceUnitLabel = UILabel()
    
    private let sizeMultiplier: CGFloat
    
    init(sizeMultiplier: CGFloat = 1.0, isVertical: Bool = false) {
        self.sizeMultiplier = sizeMultiplier
        super.init(frame: .zero)
        setupView()
    }
    
    required init?(coder: NSCoder) {
        self.sizeMultiplier = 1.0
        super.init(coder: coder)
        setupView()
    }
    
    private func setupView() {
        let etaValueFontSize: CGFloat = 20 * sizeMultiplier
        let etaValueFont = UIFont.systemFont(ofSize: etaValueFontSize, weight: .bold)
        etaValueLabel.font = etaValueFont
        etaValueLabel.textColor = .white
        etaValueLabel.textAlignment = .left
        etaValueLabel.adjustsFontSizeToFitWidth = false
        etaValueLabel.numberOfLines = 1
        etaValueLabel.translatesAutoresizingMaskIntoConstraints = false
        addSubview(etaValueLabel)
        
        let etaTitleFontSize: CGFloat = 11 * sizeMultiplier
        let etaTitleFont = UIFont.systemFont(ofSize: etaTitleFontSize, weight: .regular)
        etaTitleLabel.text = "arrivée"
        etaTitleLabel.font = etaTitleFont
        etaTitleLabel.textColor = .white
        etaTitleLabel.textAlignment = .left
        etaTitleLabel.adjustsFontSizeToFitWidth = false
        etaTitleLabel.numberOfLines = 1
        etaTitleLabel.translatesAutoresizingMaskIntoConstraints = false
        addSubview(etaTitleLabel)
        
        let timeValueFontSize: CGFloat = 20 * sizeMultiplier
        let timeValueFont = UIFont.systemFont(ofSize: timeValueFontSize, weight: .bold)
        remainingTimeValueLabel.font = timeValueFont
        remainingTimeValueLabel.textColor = UIColor.systemGreen // Green color like in image
        remainingTimeValueLabel.textAlignment = .center
        remainingTimeValueLabel.adjustsFontSizeToFitWidth = false
        remainingTimeValueLabel.numberOfLines = 1
        remainingTimeValueLabel.translatesAutoresizingMaskIntoConstraints = false
        addSubview(remainingTimeValueLabel)
        
        let timeUnitFontSize: CGFloat = 11 * sizeMultiplier
        let timeUnitFont = UIFont.systemFont(ofSize: timeUnitFontSize, weight: .regular)
        remainingTimeUnitLabel.font = timeUnitFont
        remainingTimeUnitLabel.textColor = UIColor.systemGreen
        remainingTimeUnitLabel.textAlignment = .center
        remainingTimeUnitLabel.adjustsFontSizeToFitWidth = false
        remainingTimeUnitLabel.numberOfLines = 1
        remainingTimeUnitLabel.translatesAutoresizingMaskIntoConstraints = false
        addSubview(remainingTimeUnitLabel)
        
        let distanceValueFontSize: CGFloat = 20 * sizeMultiplier
        let distanceValueFont = UIFont.systemFont(ofSize: distanceValueFontSize, weight: .bold)
        remainingDistanceValueLabel.font = distanceValueFont
        remainingDistanceValueLabel.textColor = .white
        remainingDistanceValueLabel.textAlignment = .center
        remainingDistanceValueLabel.adjustsFontSizeToFitWidth = false
        remainingDistanceValueLabel.numberOfLines = 1
        remainingDistanceValueLabel.translatesAutoresizingMaskIntoConstraints = false
        addSubview(remainingDistanceValueLabel)
        
        let distanceUnitFontSize: CGFloat = 11 * sizeMultiplier
        let distanceUnitFont = UIFont.systemFont(ofSize: distanceUnitFontSize, weight: .regular)
        remainingDistanceUnitLabel.font = distanceUnitFont
        remainingDistanceUnitLabel.textColor = .white
        remainingDistanceUnitLabel.textAlignment = .center
        remainingDistanceUnitLabel.adjustsFontSizeToFitWidth = false
        remainingDistanceUnitLabel.numberOfLines = 1
        remainingDistanceUnitLabel.translatesAutoresizingMaskIntoConstraints = false
        addSubview(remainingDistanceUnitLabel)
        
        NSLayoutConstraint.activate([
            etaValueLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
            etaValueLabel.topAnchor.constraint(equalTo: topAnchor),
            
            etaTitleLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
            etaTitleLabel.topAnchor.constraint(equalTo: etaValueLabel.bottomAnchor, constant: -2 * sizeMultiplier),
            
            remainingTimeValueLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
            remainingTimeValueLabel.firstBaselineAnchor.constraint(equalTo: etaValueLabel.firstBaselineAnchor),
            
            remainingTimeUnitLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
            remainingTimeUnitLabel.firstBaselineAnchor.constraint(equalTo: etaTitleLabel.firstBaselineAnchor),
            
            remainingDistanceValueLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
            remainingDistanceValueLabel.firstBaselineAnchor.constraint(equalTo: etaValueLabel.firstBaselineAnchor),
            
            remainingDistanceUnitLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
            remainingDistanceUnitLabel.firstBaselineAnchor.constraint(equalTo: etaTitleLabel.firstBaselineAnchor),
            
            remainingDistanceUnitLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0)
        ])
    }
    
    func update(remainingTime: TimeInterval?, remainingDistance: CLLocationDistance?, eta: String) {
        // ETA
        etaValueLabel.text = eta
        
        // Remaining time - format as "X" and "min"
        if let time = remainingTime {
            let minutes = Int(time) / 60
            remainingTimeValueLabel.text = "\(minutes)"
            remainingTimeUnitLabel.text = "min"
        } else {
            remainingTimeValueLabel.text = "--"
            remainingTimeUnitLabel.text = ""
        }
        
        if let distance = remainingDistance {
            if distance >= 1000 {
                let km = distance / 1000.0
                remainingDistanceValueLabel.text = String(format: "%.1f", km)
                remainingDistanceUnitLabel.text = "km"
            } else {
                remainingDistanceValueLabel.text = String(format: "%.0f", distance)
                remainingDistanceUnitLabel.text = "m"
            }
        } else {
            remainingDistanceValueLabel.text = "--"
            remainingDistanceUnitLabel.text = ""
        }
    }
}

Metadata

Metadata

Assignees

Labels

triage meI really want to be triaged.type: feature request‘Nice-to-have’ improvement, new feature or different behavior or design.

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions