import 'dart:io' show Platform; import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/material.dart'; import 'package:google_navigation_flutter/google_navigation_flutter.dart'; import '../models/delivery.dart'; import '../theme/color_system.dart'; import '../utils/toast_helper.dart'; /// Enhanced dark-mode aware map component with custom styling class DarkModeMapComponent extends StatefulWidget { final List deliveries; final Delivery? selectedDelivery; final ValueChanged? onDeliverySelected; final Function(String)? onAction; const DarkModeMapComponent({ super.key, required this.deliveries, this.selectedDelivery, this.onDeliverySelected, this.onAction, }); @override State createState() => _DarkModeMapComponentState(); } class _DarkModeMapComponentState extends State { GoogleNavigationViewController? _navigationController; bool _isNavigating = false; LatLng? _destinationLocation; bool _isSessionInitialized = false; bool _isInitializing = false; bool _isStartingNavigation = false; String _loadingMessage = 'Initializing...'; Brightness? _lastBrightness; bool _isMapViewReady = false; bool _isDisposed = false; @override void initState() { super.initState(); _initializeNavigation(); } @override void dispose() { _isDisposed = true; _navigationController = null; super.dispose(); } @override void didUpdateWidget(DarkModeMapComponent oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.selectedDelivery != widget.selectedDelivery) { _updateDestination(); // If navigation was active, restart navigation to new delivery if (_isNavigating && widget.selectedDelivery != null && widget.selectedDelivery!.deliveryAddress != null) { _restartNavigationToNewDelivery(); } } } @override void didChangeDependencies() { super.didChangeDependencies(); // Detect theme changes and reapply map style final currentBrightness = Theme.of(context).brightness; if (_lastBrightness != null && _lastBrightness != currentBrightness && _navigationController != null && !_isDisposed) { _applyDarkModeStyle(); } _lastBrightness = currentBrightness; } Future _restartNavigationToNewDelivery() async { try { // Stop current navigation await _stopNavigation(); // Wait a bit for stop to complete await Future.delayed(const Duration(milliseconds: 300)); // Start navigation to new delivery if (mounted && !_isDisposed) { await _startNavigation(); } } catch (e) { debugPrint('Restart navigation error: $e'); } } Future _initializeNavigation() async { if (_isInitializing || _isSessionInitialized) return; setState(() { _isInitializing = true; }); try { final termsAccepted = await GoogleMapsNavigator.areTermsAccepted(); if (!termsAccepted) { await GoogleMapsNavigator.showTermsAndConditionsDialog( 'Plan B Logistics', 'com.goutezplanb.planbLogistic', ); } await GoogleMapsNavigator.initializeNavigationSession(); if (mounted) { setState(() { _isSessionInitialized = true; _isInitializing = false; }); } } catch (e) { final errorMessage = _formatErrorMessage(e); debugPrint('Map initialization error: $errorMessage'); if (mounted) { setState(() { _isInitializing = false; }); ToastHelper.showError(context, 'Navigation initialization failed: $errorMessage'); } } } String _formatErrorMessage(Object error) { final errorString = error.toString(); if (errorString.contains('SessionNotInitializedException')) { return 'Google Maps navigation session could not be initialized'; } else if (errorString.contains('permission')) { return 'Location permission is required for navigation'; } else if (errorString.contains('network')) { return 'Network connection error'; } return errorString; } void _updateDestination() { if (widget.selectedDelivery != null) { final address = widget.selectedDelivery!.deliveryAddress; if (address?.latitude != null && address?.longitude != null) { setState(() { _destinationLocation = LatLng( latitude: address!.latitude!, longitude: address.longitude!, ); }); // Just store the destination, don't move camera // The navigation will handle camera positioning } } } Future _applyDarkModeStyle() async { // Check if widget is still mounted and controller exists if (!mounted || _navigationController == null || _isDisposed || !_isMapViewReady) return; try { if (!mounted || _isDisposed) return; // Force dark mode map style using Google's standard dark theme const String darkMapStyle = ''' [ { "elementType": "geometry", "stylers": [{"color": "#242f3e"}] }, { "elementType": "labels.text.stroke", "stylers": [{"color": "#242f3e"}] }, { "elementType": "labels.text.fill", "stylers": [{"color": "#746855"}] }, { "featureType": "administrative.locality", "elementType": "labels.text.fill", "stylers": [{"color": "#d59563"}] }, { "featureType": "poi", "elementType": "labels.text.fill", "stylers": [{"color": "#d59563"}] }, { "featureType": "poi.park", "elementType": "geometry", "stylers": [{"color": "#263c3f"}] }, { "featureType": "poi.park", "elementType": "labels.text.fill", "stylers": [{"color": "#6b9a76"}] }, { "featureType": "road", "elementType": "geometry", "stylers": [{"color": "#38414e"}] }, { "featureType": "road", "elementType": "geometry.stroke", "stylers": [{"color": "#212a37"}] }, { "featureType": "road", "elementType": "labels.text.fill", "stylers": [{"color": "#9ca5b3"}] }, { "featureType": "road.highway", "elementType": "geometry", "stylers": [{"color": "#746855"}] }, { "featureType": "road.highway", "elementType": "geometry.stroke", "stylers": [{"color": "#1f2835"}] }, { "featureType": "road.highway", "elementType": "labels.text.fill", "stylers": [{"color": "#f3d19c"}] }, { "featureType": "transit", "elementType": "geometry", "stylers": [{"color": "#2f3948"}] }, { "featureType": "transit.station", "elementType": "labels.text.fill", "stylers": [{"color": "#d59563"}] }, { "featureType": "water", "elementType": "geometry", "stylers": [{"color": "#17263c"}] }, { "featureType": "water", "elementType": "labels.text.fill", "stylers": [{"color": "#515c6d"}] }, { "featureType": "water", "elementType": "labels.text.stroke", "stylers": [{"color": "#17263c"}] } ] '''; await _navigationController!.setMapStyle(darkMapStyle); debugPrint('Dark mode map style applied'); } catch (e) { if (mounted) { debugPrint('Error applying map style: $e'); } } } Future _startNavigation() async { if (_destinationLocation == null) return; // Show loading indicator if (mounted) { setState(() { _isStartingNavigation = true; _loadingMessage = 'Starting navigation...'; }); } try { // Ensure session is initialized before starting navigation if (!_isSessionInitialized && !_isInitializing) { debugPrint('Initializing navigation session...'); await _initializeNavigation(); } // Wait for initialization to complete if it's in progress int retries = 0; while (!_isSessionInitialized && retries < 30) { await Future.delayed(const Duration(milliseconds: 100)); retries++; } if (!_isSessionInitialized) { if (mounted) { setState(() { _isStartingNavigation = false; }); ToastHelper.showError(context, 'Navigation initialization timeout'); } return; } if (mounted) { setState(() { _loadingMessage = 'Setting destination...'; }); } final waypoint = NavigationWaypoint.withLatLngTarget( title: widget.selectedDelivery?.name ?? 'Destination', target: _destinationLocation!, ); final destinations = Destinations( waypoints: [waypoint], displayOptions: NavigationDisplayOptions(showDestinationMarkers: true), ); if (mounted) { setState(() { _loadingMessage = 'Starting guidance...'; }); } debugPrint('Setting destinations: ${_destinationLocation!.latitude}, ${_destinationLocation!.longitude}'); await GoogleMapsNavigator.setDestinations(destinations); debugPrint('Starting guidance...'); await GoogleMapsNavigator.startGuidance(); debugPrint('Navigation started successfully'); // On iOS Simulator in debug mode, start simulation to provide location updates // The iOS Simulator doesn't provide continuous location updates for custom locations, // so we use the SDK's built-in simulation to simulate driving along the route. // This is only needed for testing on iOS Simulator - real devices work without this. if (kDebugMode && Platform.isIOS) { try { // Start simulating the route with a speed multiplier for testing // speedMultiplier: 1.0 = normal speed, 5.0 = 5x faster for quicker testing await GoogleMapsNavigator.simulator.simulateLocationsAlongExistingRouteWithOptions( SimulationOptions(speedMultiplier: 5.0), ); debugPrint('Simulation started for iOS Simulator testing'); } catch (e) { debugPrint('Could not start simulation: $e'); } } // Reapply dark mode style after navigation starts if (mounted) { await _applyDarkModeStyle(); } // Auto-recenter on driver location when navigation starts await _recenterMap(); debugPrint('Camera recentered on driver location'); if (mounted) { setState(() { _isNavigating = true; _isStartingNavigation = false; }); } } catch (e) { final errorMessage = _formatErrorMessage(e); debugPrint('Navigation start error: $errorMessage'); debugPrint('Full error: $e'); if (mounted) { setState(() { _isStartingNavigation = false; }); ToastHelper.showError(context, 'Navigation error: $errorMessage', duration: const Duration(seconds: 4)); } } } Future _stopNavigation() async { try { // Stop simulation if it was running (iOS Simulator) if (kDebugMode && Platform.isIOS) { try { // Remove simulated user location to stop the simulation await GoogleMapsNavigator.simulator.removeUserLocation(); debugPrint('Simulation stopped'); } catch (e) { debugPrint('Could not stop simulation: $e'); } } await GoogleMapsNavigator.stopGuidance(); await GoogleMapsNavigator.clearDestinations(); if (mounted) { setState(() { _isNavigating = false; }); } } catch (e) { if (mounted) { debugPrint('Navigation stop error: $e'); } } } Future _recenterMap() async { if (_navigationController == null) return; try { // Use the navigation controller's follow location feature // This tells the navigation to follow the driver's current location await _navigationController!.followMyLocation(CameraPerspective.tilted); debugPrint('Navigation set to follow driver location'); } catch (e) { debugPrint('Recenter map error: $e'); } } bool _hasNotes() { if (widget.selectedDelivery == null) return false; return widget.selectedDelivery!.orders.any((order) => order.note != null && order.note!.isNotEmpty ); } @override Widget build(BuildContext context) { // Driver's current location (defaults to Montreal if not available) final initialPosition = const LatLng(latitude: 45.5017, longitude: -73.5673); // Calculate dynamic padding for bottom button bar final topPadding = 0.0; final bottomPadding = 60.0; return Stack( children: [ // Map with padding to accommodate overlaid elements Padding( padding: EdgeInsets.only( top: topPadding, bottom: bottomPadding, ), child: GoogleMapsNavigationView( // Enable navigation UI automatically when guidance starts // This is critical for iOS to display turn-by-turn directions, ETA, distance initialNavigationUIEnabledPreference: NavigationUIEnabledPreference.automatic, onViewCreated: (controller) async { // Early exit if widget is already disposed if (_isDisposed || !mounted) return; _navigationController = controller; // Wait longer for the map to be fully initialized on Android // This helps prevent crashes when the view is disposed during initialization await Future.delayed(const Duration(milliseconds: 1500)); // Safety check: ensure widget is still mounted before proceeding if (!mounted || _isDisposed) { _navigationController = null; return; } // Mark map as ready only after the delay _isMapViewReady = true; // Enable navigation UI elements (header with turn directions, footer with ETA/distance) // This is required for iOS to show trip info, duration, and ETA try { if (!mounted || _isDisposed) return; await controller.setNavigationUIEnabled(true); if (!mounted || _isDisposed) return; await controller.setNavigationHeaderEnabled(true); if (!mounted || _isDisposed) return; await controller.setNavigationFooterEnabled(true); if (!mounted || _isDisposed) return; await controller.setNavigationTripProgressBarEnabled(true); if (!mounted || _isDisposed) return; // Disable report incident button await controller.setReportIncidentButtonEnabled(false); debugPrint('Navigation UI elements enabled'); // Configure map settings to reduce GPU load for devices with limited graphics capabilities if (!mounted || _isDisposed) return; await controller.settings.setTrafficEnabled(true); if (!mounted || _isDisposed) return; await controller.settings.setRotateGesturesEnabled(true); if (!mounted || _isDisposed) return; await controller.settings.setTiltGesturesEnabled(false); if (!mounted || _isDisposed) return; debugPrint('Map settings configured for performance'); } catch (e) { debugPrint('Error configuring map: $e'); if (_isDisposed || !mounted) return; } if (!mounted || _isDisposed) return; await _applyDarkModeStyle(); // Wrap camera animation in try-catch to handle "No valid view found" errors // This can happen on Android when the view isn't fully ready try { if (mounted && _navigationController != null && _isMapViewReady && !_isDisposed) { await controller.animateCamera( CameraUpdate.newLatLngZoom(initialPosition, 12), ); // Auto-recenter to current location after initial setup await Future.delayed(const Duration(milliseconds: 500)); if (mounted && _navigationController != null && !_isDisposed) { await _recenterMap(); debugPrint('Auto-recentered map to current location on initialization'); } } } catch (e) { debugPrint('Camera animation error (view may not be ready): $e'); if (_isDisposed || !mounted) return; // Retry once after a longer delay await Future.delayed(const Duration(milliseconds: 1500)); if (mounted && _navigationController != null && _isMapViewReady && !_isDisposed) { try { await controller.animateCamera( CameraUpdate.newLatLngZoom(initialPosition, 12), ); // Auto-recenter to current location after retry await Future.delayed(const Duration(milliseconds: 500)); if (mounted && _navigationController != null && !_isDisposed) { await _recenterMap(); debugPrint('Auto-recentered map to current location on initialization (retry)'); } } catch (e2) { debugPrint('Camera animation retry failed: $e2'); } } } }, initialCameraPosition: CameraPosition( target: initialPosition, zoom: 12, ), ), ), // Bottom action button bar - 4 equal-width buttons (always visible) Positioned( bottom: 0, left: 0, right: 0, child: Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.2), blurRadius: 8, offset: const Offset(0, -2), ), ], ), padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 8, ), child: Row( children: [ // Start button Expanded( child: _buildBottomActionButton( label: _isNavigating ? 'Stop' : 'Start', icon: _isNavigating ? Icons.stop : Icons.navigation, onPressed: _isStartingNavigation || _isInitializing || (widget.selectedDelivery == null && !_isNavigating) ? null : (_isNavigating ? _stopNavigation : _startNavigation), isDanger: _isNavigating, ), ), const SizedBox(width: 8), // Photo button Expanded( child: _buildBottomActionButton( label: 'Photo', icon: Icons.camera_alt, onPressed: () => widget.onAction?.call('photo'), ), ), const SizedBox(width: 8), // Note button (only enabled if delivery has notes) Expanded( child: _buildBottomActionButton( label: 'Note', icon: Icons.note_add, onPressed: _hasNotes() ? () => widget.onAction?.call('note') : null, ), ), const SizedBox(width: 8), // Completed button Expanded( child: _buildBottomActionButton( label: widget.selectedDelivery?.delivered == true ? 'Undo' : 'Completed', icon: widget.selectedDelivery?.delivered == true ? Icons.undo : Icons.check_circle, onPressed: widget.selectedDelivery != null ? () => widget.onAction?.call( widget.selectedDelivery!.delivered ? 'uncomplete' : 'complete', ) : null, isPrimary: widget.selectedDelivery != null && !widget.selectedDelivery!.delivered, ), ), ], ), ), ), // Loading overlay during navigation initialization and start if (_isStartingNavigation || _isInitializing) Positioned.fill( child: Container( color: Colors.black.withValues(alpha: 0.4), child: Center( child: Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.3), blurRadius: 12, offset: const Offset(0, 4), ), ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ // Circular progress indicator SizedBox( width: 60, height: 60, child: CircularProgressIndicator( valueColor: AlwaysStoppedAnimation( Theme.of(context).colorScheme.primary, ), strokeWidth: 3, ), ), const SizedBox(height: 16), // Loading message Text( _loadingMessage, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), textAlign: TextAlign.center, ), const SizedBox(height: 8), // Secondary message Text( 'Please wait...', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), ), textAlign: TextAlign.center, ), ], ), ), ), ), ), ], ); } Widget _buildBottomActionButton({ required String label, required IconData icon, required VoidCallback? onPressed, bool isPrimary = false, bool isDanger = false, }) { Color backgroundColor; Color textColor = Colors.white; if (isDanger) { backgroundColor = SvrntyColors.crimsonRed; } else if (isPrimary) { backgroundColor = SvrntyColors.crimsonRed; } else { // Use the same slateGray as delivery list badges backgroundColor = SvrntyColors.slateGray; } // Reduce opacity when disabled if (onPressed == null) { backgroundColor = backgroundColor.withValues(alpha: 0.5); } return Material( color: backgroundColor, borderRadius: BorderRadius.circular(6), child: InkWell( onTap: onPressed, borderRadius: BorderRadius.circular(6), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 10, ), child: Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Icon( icon, color: textColor, size: 18, ), const SizedBox(width: 6), Text( label, style: TextStyle( color: textColor, fontWeight: FontWeight.w600, fontSize: 14, ), ), ], ), ), ), ); } Widget _buildActionButton({ required String label, required IconData icon, required VoidCallback? onPressed, required Color color, }) { final isDisabled = onPressed == null; final buttonColor = isDisabled ? color.withValues(alpha: 0.5) : color; return Container( margin: const EdgeInsets.only(bottom: 8), decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.3), blurRadius: 4, offset: const Offset(0, 2), ), ], ), child: Material( color: buttonColor, borderRadius: BorderRadius.circular(8), child: InkWell( onTap: onPressed, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 8, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( icon, color: Colors.white, size: 18, ), const SizedBox(width: 6), Text( label, style: const TextStyle( color: Colors.white, fontWeight: FontWeight.w500, fontSize: 14, ), ), ], ), ), ), ), ); } }