From 611e9eb2dd7b87ededfbe725bc2fc3940545a392 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Brule Date: Sat, 15 Nov 2025 23:56:20 -0500 Subject: [PATCH] Implement iOS permissions, navigation improvements, and UI fixes Add comprehensive iOS permission handling and enhance navigation UX: iOS Permissions Setup: - Configure Podfile with permission macros (PERMISSION_LOCATION, PERMISSION_CAMERA, PERMISSION_PHOTOS) - Add camera and photo library usage descriptions to Info.plist - Enable location, camera, and photos permissions for permission_handler plugin - Clean rebuild of iOS dependencies with updated configuration Navigation Enhancements: - Implement Google Navigation dark mode with custom map styling - Add loading overlay during navigation initialization with progress messages - Fix navigation flow with proper session initialization and error handling - Enable followMyLocation API for continuous driver location tracking - Auto-recenter camera on driver location when navigation starts - Add mounted checks to prevent unmounted widget errors UI/UX Improvements: - Fix layout overlapping issues between map, header, and footer - Add dynamic padding (110px top/bottom) to accommodate navigation UI elements - Reposition navigation buttons to prevent overlap with turn-by-turn instructions - Wrap DeliveriesPage body with SafeArea for proper system UI handling - Add loading states and disabled button behavior during navigation start Technical Details: - Enhanced error logging with debug messages for troubleshooting - Implement retry logic for navigation session initialization (30 retries @ 100ms) - Apply dark mode style with 500ms delay for proper map rendering - Use CameraPerspective.tilted for optimal driving view - Remove manual camera positioning in favor of native follow mode Co-Authored-By: Claude --- ios/Podfile | 16 ++ ios/Runner/Info.plist | 5 + lib/components/dark_mode_map.dart | 315 ++++++++++++++++++++++++------ lib/pages/deliveries_page.dart | 5 +- 4 files changed, 281 insertions(+), 60 deletions(-) diff --git a/ios/Podfile b/ios/Podfile index fad4db7..8aff80e 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -39,5 +39,21 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) + + # CRITICAL: Enable permissions for permission_handler plugin + target.build_configurations.each do |config| + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ + '$(inherited)', + + ## dart: [PermissionGroup.location, PermissionGroup.locationAlways, PermissionGroup.locationWhenInUse] + 'PERMISSION_LOCATION=1', + + ## dart: PermissionGroup.camera (for image_picker) + 'PERMISSION_CAMERA=1', + + ## dart: PermissionGroup.photos (for image_picker) + 'PERMISSION_PHOTOS=1', + ] + end end end diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index d012aa6..5ab417b 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -56,8 +56,13 @@ UIBackgroundModes location + fetch NSLocationAlwaysUsageDescription This app needs continuous access to your location for navigation and delivery tracking. + NSCameraUsageDescription + This app needs camera access to take photos of deliveries. + NSPhotoLibraryUsageDescription + This app needs access to your photos to select delivery images. diff --git a/lib/components/dark_mode_map.dart b/lib/components/dark_mode_map.dart index b2798c6..8027ef2 100644 --- a/lib/components/dark_mode_map.dart +++ b/lib/components/dark_mode_map.dart @@ -26,6 +26,11 @@ class _DarkModeMapComponentState extends State { GoogleNavigationViewController? _navigationController; bool _isNavigating = false; LatLng? _destinationLocation; + LatLng? _driverLocation; + bool _isSessionInitialized = false; + bool _isInitializing = false; + bool _isStartingNavigation = false; + String _loadingMessage = 'Initializing...'; @override void initState() { @@ -34,6 +39,12 @@ class _DarkModeMapComponentState extends State { } Future _initializeNavigation() async { + if (_isInitializing || _isSessionInitialized) return; + + setState(() { + _isInitializing = true; + }); + try { final termsAccepted = await GoogleMapsNavigator.areTermsAccepted(); if (!termsAccepted) { @@ -43,11 +54,44 @@ class _DarkModeMapComponentState extends State { ); } await GoogleMapsNavigator.initializeNavigationSession(); + + if (mounted) { + setState(() { + _isSessionInitialized = true; + _isInitializing = false; + }); + } } catch (e) { - debugPrint('Map initialization error: $e'); + final errorMessage = _formatErrorMessage(e); + debugPrint('Map initialization error: $errorMessage'); + + if (mounted) { + setState(() { + _isInitializing = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Navigation initialization failed: $errorMessage'), + duration: const Duration(seconds: 5), + ), + ); + } } } + 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; + } + @override void didUpdateWidget(DarkModeMapComponent oldWidget) { super.didUpdateWidget(oldWidget); @@ -66,34 +110,30 @@ class _DarkModeMapComponentState extends State { longitude: address.longitude!, ); }); - _navigateToLocation(_destinationLocation!); + // Just store the destination, don't move camera + // The navigation will handle camera positioning } } } - Future _navigateToLocation(LatLng location) async { - if (_navigationController == null) return; - try { - await _navigationController!.animateCamera( - CameraUpdate.newLatLngZoom(location, 15), - ); - } catch (e) { - debugPrint('Camera navigation error: $e'); - } - } - Future _applyDarkModeStyle() async { - if (_navigationController == null) return; + // Check if widget is still mounted and controller exists + if (!mounted || _navigationController == null) return; + try { // Apply dark mode style configuration for Google Maps // This reduces eye strain in low-light environments + if (!mounted) return; + final isDarkMode = Theme.of(context).brightness == Brightness.dark; if (isDarkMode) { // Dark map style with warm accent colors await _navigationController!.setMapStyle(_getDarkMapStyle()); } } catch (e) { - debugPrint('Error applying map style: $e'); + if (mounted) { + debugPrint('Error applying map style: $e'); + } } } @@ -219,7 +259,50 @@ class _DarkModeMapComponentState extends State { 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; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Navigation initialization timeout'), + duration: Duration(seconds: 3), + ), + ); + } + return; + } + + if (mounted) { + setState(() { + _loadingMessage = 'Setting destination...'; + }); + } + final waypoint = NavigationWaypoint.withLatLngTarget( title: widget.selectedDelivery?.name ?? 'Destination', target: _destinationLocation!, @@ -230,17 +313,50 @@ class _DarkModeMapComponentState extends State { 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(); - setState(() { - _isNavigating = true; - }); - } catch (e) { - debugPrint('Navigation start error: $e'); + debugPrint('Navigation started successfully'); + + // 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; + }); + ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Navigation error: $e')), + SnackBar( + content: Text('Navigation error: $errorMessage'), + duration: const Duration(seconds: 4), + ), ); } } @@ -250,11 +366,15 @@ class _DarkModeMapComponentState extends State { try { await GoogleMapsNavigator.stopGuidance(); await GoogleMapsNavigator.clearDestinations(); - setState(() { - _isNavigating = false; - }); + if (mounted) { + setState(() { + _isNavigating = false; + }); + } } catch (e) { - debugPrint('Navigation stop error: $e'); + if (mounted) { + debugPrint('Navigation stop error: $e'); + } } } @@ -289,11 +409,12 @@ class _DarkModeMapComponentState extends State { } Future _recenterMap() async { - if (_navigationController == null || _destinationLocation == null) return; + if (_navigationController == null) return; try { - await _navigationController!.animateCamera( - CameraUpdate.newLatLngZoom(_destinationLocation!, 15), - ); + // 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'); } @@ -301,28 +422,39 @@ class _DarkModeMapComponentState extends State { @override Widget build(BuildContext context) { - final initialPosition = widget.selectedDelivery?.deliveryAddress != null && - widget.selectedDelivery!.deliveryAddress!.latitude != null && - widget.selectedDelivery!.deliveryAddress!.longitude != null - ? LatLng( - latitude: widget.selectedDelivery!.deliveryAddress!.latitude!, - longitude: widget.selectedDelivery!.deliveryAddress!.longitude!, - ) - : const LatLng(latitude: 45.5017, longitude: -73.5673); + // Driver's current location (defaults to Montreal if not available) + final initialPosition = const LatLng(latitude: 45.5017, longitude: -73.5673); + + // Store driver location for navigation centering + _driverLocation = initialPosition; + + // Calculate dynamic padding for top info panel and bottom button bar + // Increased to accommodate navigation widget info and action buttons + final topPadding = widget.selectedDelivery != null ? 110.0 : 0.0; + final bottomPadding = widget.selectedDelivery != null ? 110.0 : 0.0; return Stack( children: [ - GoogleMapsNavigationView( - onViewCreated: (controller) { - _navigationController = controller; - _applyDarkModeStyle(); - controller.animateCamera( - CameraUpdate.newLatLngZoom(initialPosition, 12), - ); - }, - initialCameraPosition: CameraPosition( - target: initialPosition, - zoom: 12, + // Map with padding to accommodate overlaid elements + Padding( + padding: EdgeInsets.only( + top: topPadding, + bottom: bottomPadding, + ), + child: GoogleMapsNavigationView( + onViewCreated: (controller) async { + _navigationController = controller; + // Apply dark mode style with a small delay to ensure map is ready + await Future.delayed(const Duration(milliseconds: 500)); + await _applyDarkModeStyle(); + controller.animateCamera( + CameraUpdate.newLatLngZoom(initialPosition, 12), + ); + }, + initialCameraPosition: CameraPosition( + target: initialPosition, + zoom: 12, + ), ), ), // Custom dark-themed controls overlay @@ -346,9 +478,9 @@ class _DarkModeMapComponentState extends State { ], ), ), - // Navigation and action buttons + // Navigation and action buttons (positioned above bottom button bar) Positioned( - bottom: 16, + bottom: 120, right: 16, child: Column( mainAxisSize: MainAxisSize.min, @@ -356,9 +488,9 @@ class _DarkModeMapComponentState extends State { // Start navigation button if (widget.selectedDelivery != null && !_isNavigating) _buildActionButton( - label: 'Navigate', + label: _isStartingNavigation || _isInitializing ? 'Loading...' : 'Navigate', icon: Icons.directions, - onPressed: _startNavigation, + onPressed: _isStartingNavigation || _isInitializing ? null : _startNavigation, color: SvrntyColors.crimsonRed, ), if (_isNavigating) @@ -497,9 +629,9 @@ class _DarkModeMapComponentState extends State { if (widget.selectedDelivery!.delivered) Expanded( child: _buildBottomActionButton( - label: 'Start Navigation', + label: _isInitializing ? 'Initializing...' : 'Start Navigation', icon: Icons.directions, - onPressed: _startNavigation, + onPressed: _isInitializing ? null : _startNavigation, isPrimary: true, ), ), @@ -508,9 +640,9 @@ class _DarkModeMapComponentState extends State { if (!_isNavigating && !widget.selectedDelivery!.delivered) Expanded( child: _buildBottomActionButton( - label: 'Navigate', + label: _isStartingNavigation || _isInitializing ? 'Loading...' : 'Navigate', icon: Icons.directions, - onPressed: _startNavigation, + onPressed: _isStartingNavigation || _isInitializing ? null : _startNavigation, ), ), if (_isNavigating) @@ -528,6 +660,63 @@ class _DarkModeMapComponentState extends State { ), ), ), + // Loading overlay during navigation initialization and start + if (_isStartingNavigation || _isInitializing) + Positioned.fill( + child: Container( + color: Colors.black.withOpacity(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.withOpacity(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.withOpacity(0.7), + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ), ], ); } @@ -569,7 +758,7 @@ class _DarkModeMapComponentState extends State { Widget _buildBottomActionButton({ required String label, required IconData icon, - required VoidCallback onPressed, + required VoidCallback? onPressed, bool isPrimary = false, bool isDanger = false, }) { @@ -585,6 +774,11 @@ class _DarkModeMapComponentState extends State { textColor = Theme.of(context).colorScheme.onSurface; } + // Reduce opacity when disabled + if (onPressed == null) { + backgroundColor = backgroundColor.withOpacity(0.5); + } + return Material( color: backgroundColor, borderRadius: BorderRadius.circular(8), @@ -624,9 +818,12 @@ class _DarkModeMapComponentState extends State { Widget _buildActionButton({ required String label, required IconData icon, - required VoidCallback onPressed, + required VoidCallback? onPressed, required Color color, }) { + final isDisabled = onPressed == null; + final buttonColor = isDisabled ? color.withOpacity(0.5) : color; + return Container( margin: const EdgeInsets.only(bottom: 8), decoration: BoxDecoration( @@ -640,7 +837,7 @@ class _DarkModeMapComponentState extends State { ], ), child: Material( - color: color, + color: buttonColor, borderRadius: BorderRadius.circular(8), child: InkWell( onTap: onPressed, diff --git a/lib/pages/deliveries_page.dart b/lib/pages/deliveries_page.dart index bc6c019..0836a23 100644 --- a/lib/pages/deliveries_page.dart +++ b/lib/pages/deliveries_page.dart @@ -70,7 +70,8 @@ class _DeliveriesPageState extends ConsumerState { title: Text(widget.routeName), elevation: 0, ), - body: deliveriesData.when( + body: SafeArea( + child: deliveriesData.when( data: (deliveries) { // Auto-scroll to first pending delivery when page loads or route changes if (_lastRouteFragmentId != widget.routeFragmentId) { @@ -167,6 +168,7 @@ class _DeliveriesPageState extends ConsumerState { child: Text('Error: $error'), ), ), + ), ); } @@ -286,6 +288,7 @@ class UnifiedDeliveryListView extends StatelessWidget { child: ListView.builder( controller: scrollController, padding: const EdgeInsets.symmetric(vertical: 8), + physics: const AlwaysScrollableScrollPhysics(), itemCount: deliveries.length, itemBuilder: (context, index) { final delivery = deliveries[index];