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 <noreply@anthropic.com>
This commit is contained in:
Jean-Philippe Brule 2025-11-15 23:56:20 -05:00
parent 7eb4469034
commit 611e9eb2dd
4 changed files with 281 additions and 60 deletions

View File

@ -39,5 +39,21 @@ end
post_install do |installer| post_install do |installer|
installer.pods_project.targets.each do |target| installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(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
end end

View File

@ -56,8 +56,13 @@
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>location</string> <string>location</string>
<string>fetch</string>
</array> </array>
<key>NSLocationAlwaysUsageDescription</key> <key>NSLocationAlwaysUsageDescription</key>
<string>This app needs continuous access to your location for navigation and delivery tracking.</string> <string>This app needs continuous access to your location for navigation and delivery tracking.</string>
<key>NSCameraUsageDescription</key>
<string>This app needs camera access to take photos of deliveries.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs access to your photos to select delivery images.</string>
</dict> </dict>
</plist> </plist>

View File

@ -26,6 +26,11 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
GoogleNavigationViewController? _navigationController; GoogleNavigationViewController? _navigationController;
bool _isNavigating = false; bool _isNavigating = false;
LatLng? _destinationLocation; LatLng? _destinationLocation;
LatLng? _driverLocation;
bool _isSessionInitialized = false;
bool _isInitializing = false;
bool _isStartingNavigation = false;
String _loadingMessage = 'Initializing...';
@override @override
void initState() { void initState() {
@ -34,6 +39,12 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
} }
Future<void> _initializeNavigation() async { Future<void> _initializeNavigation() async {
if (_isInitializing || _isSessionInitialized) return;
setState(() {
_isInitializing = true;
});
try { try {
final termsAccepted = await GoogleMapsNavigator.areTermsAccepted(); final termsAccepted = await GoogleMapsNavigator.areTermsAccepted();
if (!termsAccepted) { if (!termsAccepted) {
@ -43,9 +54,42 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
); );
} }
await GoogleMapsNavigator.initializeNavigationSession(); await GoogleMapsNavigator.initializeNavigationSession();
} catch (e) {
debugPrint('Map initialization error: $e'); if (mounted) {
setState(() {
_isSessionInitialized = true;
_isInitializing = false;
});
} }
} catch (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 @override
@ -66,36 +110,32 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
longitude: address.longitude!, longitude: address.longitude!,
); );
}); });
_navigateToLocation(_destinationLocation!); // Just store the destination, don't move camera
// The navigation will handle camera positioning
} }
} }
} }
Future<void> _navigateToLocation(LatLng location) async {
if (_navigationController == null) return;
try {
await _navigationController!.animateCamera(
CameraUpdate.newLatLngZoom(location, 15),
);
} catch (e) {
debugPrint('Camera navigation error: $e');
}
}
Future<void> _applyDarkModeStyle() async { Future<void> _applyDarkModeStyle() async {
if (_navigationController == null) return; // Check if widget is still mounted and controller exists
if (!mounted || _navigationController == null) return;
try { try {
// Apply dark mode style configuration for Google Maps // Apply dark mode style configuration for Google Maps
// This reduces eye strain in low-light environments // This reduces eye strain in low-light environments
if (!mounted) return;
final isDarkMode = Theme.of(context).brightness == Brightness.dark; final isDarkMode = Theme.of(context).brightness == Brightness.dark;
if (isDarkMode) { if (isDarkMode) {
// Dark map style with warm accent colors // Dark map style with warm accent colors
await _navigationController!.setMapStyle(_getDarkMapStyle()); await _navigationController!.setMapStyle(_getDarkMapStyle());
} }
} catch (e) { } catch (e) {
if (mounted) {
debugPrint('Error applying map style: $e'); debugPrint('Error applying map style: $e');
} }
} }
}
String _getDarkMapStyle() { String _getDarkMapStyle() {
// Google Maps style JSON for dark mode with warm accents // Google Maps style JSON for dark mode with warm accents
@ -219,7 +259,50 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
Future<void> _startNavigation() async { Future<void> _startNavigation() async {
if (_destinationLocation == null) return; if (_destinationLocation == null) return;
// Show loading indicator
if (mounted) {
setState(() {
_isStartingNavigation = true;
_loadingMessage = 'Starting navigation...';
});
}
try { 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( final waypoint = NavigationWaypoint.withLatLngTarget(
title: widget.selectedDelivery?.name ?? 'Destination', title: widget.selectedDelivery?.name ?? 'Destination',
target: _destinationLocation!, target: _destinationLocation!,
@ -230,17 +313,50 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
displayOptions: NavigationDisplayOptions(showDestinationMarkers: true), displayOptions: NavigationDisplayOptions(showDestinationMarkers: true),
); );
if (mounted) {
setState(() {
_loadingMessage = 'Starting guidance...';
});
}
debugPrint('Setting destinations: ${_destinationLocation!.latitude}, ${_destinationLocation!.longitude}');
await GoogleMapsNavigator.setDestinations(destinations); await GoogleMapsNavigator.setDestinations(destinations);
debugPrint('Starting guidance...');
await GoogleMapsNavigator.startGuidance(); await GoogleMapsNavigator.startGuidance();
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(() { setState(() {
_isNavigating = true; _isNavigating = true;
_isStartingNavigation = false;
}); });
}
} catch (e) { } catch (e) {
debugPrint('Navigation start error: $e'); final errorMessage = _formatErrorMessage(e);
debugPrint('Navigation start error: $errorMessage');
debugPrint('Full error: $e');
if (mounted) { if (mounted) {
setState(() {
_isStartingNavigation = false;
});
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Navigation error: $e')), SnackBar(
content: Text('Navigation error: $errorMessage'),
duration: const Duration(seconds: 4),
),
); );
} }
} }
@ -250,13 +366,17 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
try { try {
await GoogleMapsNavigator.stopGuidance(); await GoogleMapsNavigator.stopGuidance();
await GoogleMapsNavigator.clearDestinations(); await GoogleMapsNavigator.clearDestinations();
if (mounted) {
setState(() { setState(() {
_isNavigating = false; _isNavigating = false;
}); });
}
} catch (e) { } catch (e) {
if (mounted) {
debugPrint('Navigation stop error: $e'); debugPrint('Navigation stop error: $e');
} }
} }
}
Future<void> _zoomIn() async { Future<void> _zoomIn() async {
if (_navigationController == null) return; if (_navigationController == null) return;
@ -289,11 +409,12 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
} }
Future<void> _recenterMap() async { Future<void> _recenterMap() async {
if (_navigationController == null || _destinationLocation == null) return; if (_navigationController == null) return;
try { try {
await _navigationController!.animateCamera( // Use the navigation controller's follow location feature
CameraUpdate.newLatLngZoom(_destinationLocation!, 15), // 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) { } catch (e) {
debugPrint('Recenter map error: $e'); debugPrint('Recenter map error: $e');
} }
@ -301,21 +422,31 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final initialPosition = widget.selectedDelivery?.deliveryAddress != null && // Driver's current location (defaults to Montreal if not available)
widget.selectedDelivery!.deliveryAddress!.latitude != null && final initialPosition = const LatLng(latitude: 45.5017, longitude: -73.5673);
widget.selectedDelivery!.deliveryAddress!.longitude != null
? LatLng( // Store driver location for navigation centering
latitude: widget.selectedDelivery!.deliveryAddress!.latitude!, _driverLocation = initialPosition;
longitude: widget.selectedDelivery!.deliveryAddress!.longitude!,
) // Calculate dynamic padding for top info panel and bottom button bar
: const LatLng(latitude: 45.5017, longitude: -73.5673); // 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( return Stack(
children: [ children: [
GoogleMapsNavigationView( // Map with padding to accommodate overlaid elements
onViewCreated: (controller) { Padding(
padding: EdgeInsets.only(
top: topPadding,
bottom: bottomPadding,
),
child: GoogleMapsNavigationView(
onViewCreated: (controller) async {
_navigationController = controller; _navigationController = controller;
_applyDarkModeStyle(); // Apply dark mode style with a small delay to ensure map is ready
await Future.delayed(const Duration(milliseconds: 500));
await _applyDarkModeStyle();
controller.animateCamera( controller.animateCamera(
CameraUpdate.newLatLngZoom(initialPosition, 12), CameraUpdate.newLatLngZoom(initialPosition, 12),
); );
@ -325,6 +456,7 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
zoom: 12, zoom: 12,
), ),
), ),
),
// Custom dark-themed controls overlay // Custom dark-themed controls overlay
Positioned( Positioned(
top: 16, top: 16,
@ -346,9 +478,9 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
], ],
), ),
), ),
// Navigation and action buttons // Navigation and action buttons (positioned above bottom button bar)
Positioned( Positioned(
bottom: 16, bottom: 120,
right: 16, right: 16,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -356,9 +488,9 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
// Start navigation button // Start navigation button
if (widget.selectedDelivery != null && !_isNavigating) if (widget.selectedDelivery != null && !_isNavigating)
_buildActionButton( _buildActionButton(
label: 'Navigate', label: _isStartingNavigation || _isInitializing ? 'Loading...' : 'Navigate',
icon: Icons.directions, icon: Icons.directions,
onPressed: _startNavigation, onPressed: _isStartingNavigation || _isInitializing ? null : _startNavigation,
color: SvrntyColors.crimsonRed, color: SvrntyColors.crimsonRed,
), ),
if (_isNavigating) if (_isNavigating)
@ -497,9 +629,9 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
if (widget.selectedDelivery!.delivered) if (widget.selectedDelivery!.delivered)
Expanded( Expanded(
child: _buildBottomActionButton( child: _buildBottomActionButton(
label: 'Start Navigation', label: _isInitializing ? 'Initializing...' : 'Start Navigation',
icon: Icons.directions, icon: Icons.directions,
onPressed: _startNavigation, onPressed: _isInitializing ? null : _startNavigation,
isPrimary: true, isPrimary: true,
), ),
), ),
@ -508,9 +640,9 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
if (!_isNavigating && !widget.selectedDelivery!.delivered) if (!_isNavigating && !widget.selectedDelivery!.delivered)
Expanded( Expanded(
child: _buildBottomActionButton( child: _buildBottomActionButton(
label: 'Navigate', label: _isStartingNavigation || _isInitializing ? 'Loading...' : 'Navigate',
icon: Icons.directions, icon: Icons.directions,
onPressed: _startNavigation, onPressed: _isStartingNavigation || _isInitializing ? null : _startNavigation,
), ),
), ),
if (_isNavigating) if (_isNavigating)
@ -528,6 +660,63 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
), ),
), ),
), ),
// 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<Color>(
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<DarkModeMapComponent> {
Widget _buildBottomActionButton({ Widget _buildBottomActionButton({
required String label, required String label,
required IconData icon, required IconData icon,
required VoidCallback onPressed, required VoidCallback? onPressed,
bool isPrimary = false, bool isPrimary = false,
bool isDanger = false, bool isDanger = false,
}) { }) {
@ -585,6 +774,11 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
textColor = Theme.of(context).colorScheme.onSurface; textColor = Theme.of(context).colorScheme.onSurface;
} }
// Reduce opacity when disabled
if (onPressed == null) {
backgroundColor = backgroundColor.withOpacity(0.5);
}
return Material( return Material(
color: backgroundColor, color: backgroundColor,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@ -624,9 +818,12 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
Widget _buildActionButton({ Widget _buildActionButton({
required String label, required String label,
required IconData icon, required IconData icon,
required VoidCallback onPressed, required VoidCallback? onPressed,
required Color color, required Color color,
}) { }) {
final isDisabled = onPressed == null;
final buttonColor = isDisabled ? color.withOpacity(0.5) : color;
return Container( return Container(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -640,7 +837,7 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
], ],
), ),
child: Material( child: Material(
color: color, color: buttonColor,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: InkWell( child: InkWell(
onTap: onPressed, onTap: onPressed,

View File

@ -70,7 +70,8 @@ class _DeliveriesPageState extends ConsumerState<DeliveriesPage> {
title: Text(widget.routeName), title: Text(widget.routeName),
elevation: 0, elevation: 0,
), ),
body: deliveriesData.when( body: SafeArea(
child: deliveriesData.when(
data: (deliveries) { data: (deliveries) {
// Auto-scroll to first pending delivery when page loads or route changes // Auto-scroll to first pending delivery when page loads or route changes
if (_lastRouteFragmentId != widget.routeFragmentId) { if (_lastRouteFragmentId != widget.routeFragmentId) {
@ -167,6 +168,7 @@ class _DeliveriesPageState extends ConsumerState<DeliveriesPage> {
child: Text('Error: $error'), child: Text('Error: $error'),
), ),
), ),
),
); );
} }
@ -286,6 +288,7 @@ class UnifiedDeliveryListView extends StatelessWidget {
child: ListView.builder( child: ListView.builder(
controller: scrollController, controller: scrollController,
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
physics: const AlwaysScrollableScrollPhysics(),
itemCount: deliveries.length, itemCount: deliveries.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final delivery = deliveries[index]; final delivery = deliveries[index];