ionic-planb-logistic-app-fl.../lib/components/dark_mode_map.dart
Jean-Philippe Brule 611e9eb2dd 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>
2025-11-15 23:56:20 -05:00

875 lines
27 KiB
Dart

import 'package:flutter/material.dart';
import 'package:google_navigation_flutter/google_navigation_flutter.dart';
import '../models/delivery.dart';
import '../theme/color_system.dart';
/// Enhanced dark-mode aware map component with custom styling
class DarkModeMapComponent extends StatefulWidget {
final List<Delivery> deliveries;
final Delivery? selectedDelivery;
final ValueChanged<Delivery?>? onDeliverySelected;
final Function(String)? onAction;
const DarkModeMapComponent({
super.key,
required this.deliveries,
this.selectedDelivery,
this.onDeliverySelected,
this.onAction,
});
@override
State<DarkModeMapComponent> createState() => _DarkModeMapComponentState();
}
class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
GoogleNavigationViewController? _navigationController;
bool _isNavigating = false;
LatLng? _destinationLocation;
LatLng? _driverLocation;
bool _isSessionInitialized = false;
bool _isInitializing = false;
bool _isStartingNavigation = false;
String _loadingMessage = 'Initializing...';
@override
void initState() {
super.initState();
_initializeNavigation();
}
Future<void> _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;
});
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);
if (oldWidget.selectedDelivery != widget.selectedDelivery) {
_updateDestination();
}
}
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<void> _applyDarkModeStyle() async {
// 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) {
if (mounted) {
debugPrint('Error applying map style: $e');
}
}
}
String _getDarkMapStyle() {
// Google Maps style JSON for dark mode with warm accents
return '''[
{
"elementType": "geometry",
"stylers": [{"color": "#212121"}]
},
{
"elementType": "labels.icon",
"stylers": [{"visibility": "off"}]
},
{
"elementType": "labels.text.fill",
"stylers": [{"color": "#757575"}]
},
{
"elementType": "labels.text.stroke",
"stylers": [{"color": "#212121"}]
},
{
"featureType": "administrative",
"elementType": "geometry",
"stylers": [{"color": "#757575"}]
},
{
"featureType": "administrative.country",
"elementType": "labels.text.fill",
"stylers": [{"color": "#9e9e9e"}]
},
{
"featureType": "administrative.land_parcel",
"stylers": [{"visibility": "off"}]
},
{
"featureType": "administrative.locality",
"elementType": "labels.text.fill",
"stylers": [{"color": "#bdbdbd"}]
},
{
"featureType": "administrative.neighborhood",
"stylers": [{"visibility": "off"}]
},
{
"featureType": "administrative.province",
"elementType": "labels.text.fill",
"stylers": [{"color": "#9e9e9e"}]
},
{
"featureType": "landscape",
"elementType": "geometry",
"stylers": [{"color": "#000000"}]
},
{
"featureType": "poi",
"elementType": "geometry",
"stylers": [{"color": "#383838"}]
},
{
"featureType": "poi",
"elementType": "labels.text.fill",
"stylers": [{"color": "#9e9e9e"}]
},
{
"featureType": "poi.park",
"elementType": "geometry",
"stylers": [{"color": "#181818"}]
},
{
"featureType": "poi.park",
"elementType": "labels.text.fill",
"stylers": [{"color": "#616161"}]
},
{
"featureType": "road",
"elementType": "geometry.fill",
"stylers": [{"color": "#2c2c2c"}]
},
{
"featureType": "road",
"elementType": "labels.text.fill",
"stylers": [{"color": "#8a8a8a"}]
},
{
"featureType": "road.arterial",
"elementType": "geometry",
"stylers": [{"color": "#373737"}]
},
{
"featureType": "road.highway",
"elementType": "geometry",
"stylers": [{"color": "#3c3c3c"}]
},
{
"featureType": "road.highway.controlled_access",
"elementType": "geometry",
"stylers": [{"color": "#4e4e4e"}]
},
{
"featureType": "road.local",
"elementType": "labels.text.fill",
"stylers": [{"color": "#616161"}]
},
{
"featureType": "transit",
"elementType": "labels.text.fill",
"stylers": [{"color": "#757575"}]
},
{
"featureType": "water",
"elementType": "geometry",
"stylers": [{"color": "#0c1221"}]
},
{
"featureType": "water",
"elementType": "labels.text.fill",
"stylers": [{"color": "#3d3d3d"}]
}
]''';
}
Future<void> _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!,
);
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');
// 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: $errorMessage'),
duration: const Duration(seconds: 4),
),
);
}
}
}
Future<void> _stopNavigation() async {
try {
await GoogleMapsNavigator.stopGuidance();
await GoogleMapsNavigator.clearDestinations();
if (mounted) {
setState(() {
_isNavigating = false;
});
}
} catch (e) {
if (mounted) {
debugPrint('Navigation stop error: $e');
}
}
}
Future<void> _zoomIn() async {
if (_navigationController == null) return;
try {
final currentCamera = await _navigationController!.getCameraPosition();
await _navigationController!.animateCamera(
CameraUpdate.newLatLngZoom(
currentCamera.target,
currentCamera.zoom + 1,
),
);
} catch (e) {
debugPrint('Zoom in error: $e');
}
}
Future<void> _zoomOut() async {
if (_navigationController == null) return;
try {
final currentCamera = await _navigationController!.getCameraPosition();
await _navigationController!.animateCamera(
CameraUpdate.newLatLngZoom(
currentCamera.target,
currentCamera.zoom - 1,
),
);
} catch (e) {
debugPrint('Zoom out error: $e');
}
}
Future<void> _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');
}
}
@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);
// 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: [
// 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
Positioned(
top: 16,
right: 16,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Zoom in button
_buildIconButton(
icon: Icons.add,
onPressed: _zoomIn,
),
const SizedBox(height: 8),
// Zoom out button
_buildIconButton(
icon: Icons.remove,
onPressed: _zoomOut,
),
],
),
),
// Navigation and action buttons (positioned above bottom button bar)
Positioned(
bottom: 120,
right: 16,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Start navigation button
if (widget.selectedDelivery != null && !_isNavigating)
_buildActionButton(
label: _isStartingNavigation || _isInitializing ? 'Loading...' : 'Navigate',
icon: Icons.directions,
onPressed: _isStartingNavigation || _isInitializing ? null : _startNavigation,
color: SvrntyColors.crimsonRed,
),
if (_isNavigating)
_buildActionButton(
label: 'Stop',
icon: Icons.stop,
onPressed: _stopNavigation,
color: Theme.of(context).colorScheme.onSurface,
),
],
),
),
// Selected delivery info panel (dark themed)
if (widget.selectedDelivery != null)
Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.selectedDelivery!.name,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
widget.selectedDelivery!.deliveryAddress
?.formattedAddress ??
'No address',
style: Theme.of(context)
.textTheme
.bodySmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
// Status indicator
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: widget.selectedDelivery!.delivered
? SvrntyColors.statusCompleted
: SvrntyColors.statusPending,
borderRadius: BorderRadius.circular(4),
),
child: Text(
widget.selectedDelivery!.delivered
? 'Delivered'
: 'Pending',
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
// Bottom action button bar
if (widget.selectedDelivery != null)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
// Recenter button
Expanded(
child: _buildBottomActionButton(
label: 'Recenter',
icon: Icons.location_on,
onPressed: _recenterMap,
),
),
const SizedBox(width: 12),
// Mark Complete button (if not already delivered)
if (!widget.selectedDelivery!.delivered)
Expanded(
child: _buildBottomActionButton(
label: 'Mark Complete',
icon: Icons.check_circle,
onPressed: () => widget.onAction?.call('complete'),
isPrimary: true,
),
),
if (widget.selectedDelivery!.delivered)
Expanded(
child: _buildBottomActionButton(
label: _isInitializing ? 'Initializing...' : 'Start Navigation',
icon: Icons.directions,
onPressed: _isInitializing ? null : _startNavigation,
isPrimary: true,
),
),
if (!_isNavigating && !widget.selectedDelivery!.delivered)
const SizedBox(width: 12),
if (!_isNavigating && !widget.selectedDelivery!.delivered)
Expanded(
child: _buildBottomActionButton(
label: _isStartingNavigation || _isInitializing ? 'Loading...' : 'Navigate',
icon: Icons.directions,
onPressed: _isStartingNavigation || _isInitializing ? null : _startNavigation,
),
),
if (_isNavigating)
const SizedBox(width: 12),
if (_isNavigating)
Expanded(
child: _buildBottomActionButton(
label: 'Stop',
icon: Icons.stop,
onPressed: _stopNavigation,
isDanger: true,
),
),
],
),
),
),
// 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,
),
],
),
),
),
),
),
],
);
}
Widget _buildIconButton({
required IconData icon,
required VoidCallback onPressed,
}) {
return Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Material(
color: Theme.of(context).colorScheme.surface,
shape: const CircleBorder(),
child: InkWell(
onTap: onPressed,
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(12),
child: Icon(
icon,
color: Theme.of(context).colorScheme.onSurface,
size: 24,
),
),
),
),
);
}
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 = Colors.red.shade600;
} else if (isPrimary) {
backgroundColor = SvrntyColors.crimsonRed;
} else {
backgroundColor = Theme.of(context).colorScheme.surfaceContainerHighest;
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),
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
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.w500,
fontSize: 14,
),
),
],
),
),
),
);
}
Widget _buildActionButton({
required String label,
required IconData icon,
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(
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(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,
),
),
],
),
),
),
),
);
}
}