Remove the floating "Navigate" and "Stop" buttons positioned on the right side of the map. These actions are already available in the bottom button bar, so the floating buttons were redundant. Updated dark_mode_map.dart: - Removed Positioned floating button column (right: 16, bottom: 120) - Removed floating "Navigate" button (shown when delivery selected) - Removed floating "Stop" button (shown when navigating) The bottom action bar now contains all necessary navigation controls. Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
595 lines
18 KiB
Dart
595 lines
18 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;
|
|
bool _isSessionInitialized = false;
|
|
bool _isInitializing = false;
|
|
bool _isStartingNavigation = false;
|
|
String _loadingMessage = 'Initializing...';
|
|
Brightness? _lastBrightness;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_initializeNavigation();
|
|
}
|
|
|
|
@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) {
|
|
_applyDarkModeStyle();
|
|
}
|
|
_lastBrightness = currentBrightness;
|
|
}
|
|
|
|
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 {
|
|
if (!mounted) return;
|
|
|
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
|
if (isDarkMode) {
|
|
// Dark mode style - Note: Currently only supported on Android
|
|
const simpleDarkStyle = '''[
|
|
{
|
|
"elementType": "geometry",
|
|
"stylers": [{"color": "#242424"}]
|
|
},
|
|
{
|
|
"elementType": "labels.text.fill",
|
|
"stylers": [{"color": "#746855"}]
|
|
},
|
|
{
|
|
"elementType": "labels.text.stroke",
|
|
"stylers": [{"color": "#242424"}]
|
|
},
|
|
{
|
|
"featureType": "water",
|
|
"elementType": "geometry",
|
|
"stylers": [{"color": "#17263c"}]
|
|
}
|
|
]''';
|
|
|
|
await _navigationController!.setMapStyle(simpleDarkStyle);
|
|
} else {
|
|
// Reset to default light style
|
|
await _navigationController!.setMapStyle(null);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
debugPrint('Error applying map style: $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
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> _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);
|
|
|
|
// Calculate dynamic padding for bottom button bar
|
|
final topPadding = 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,
|
|
),
|
|
),
|
|
),
|
|
// 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.withValues(alpha: 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.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<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.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 = 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.withValues(alpha: 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.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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|