- Overhaul theme system with Svrnty design and WCAG AAA compliance - Remove android, macos, and web platform files (iOS-only focus) - Update components with improved dark mode map and UI refinements - Enhance settings page with additional configuration options - Add theme system documentation in lib/theme/README.md - Update CLAUDE.md with comprehensive theme guidelines Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
956 lines
34 KiB
Dart
956 lines
34 KiB
Dart
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/breakpoints.dart';
|
|
import '../utils/toast_helper.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;
|
|
bool _isMapViewReady = false;
|
|
bool _isDisposed = false;
|
|
bool _isAudioGuidanceEnabled = false; // Audio guidance off by default
|
|
bool _pendingNavigationStart = false; // User requested navigation before map was ready
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Set destination if delivery is already selected when widget is created
|
|
_updateDestination();
|
|
_initializeNavigation();
|
|
// Ensure state is synced after a brief delay to allow map to fully load
|
|
Future.delayed(const Duration(milliseconds: 500), () {
|
|
if (mounted && !_isDisposed) {
|
|
_syncNavigationState();
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_isDisposed = true;
|
|
_navigationController = null;
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(DarkModeMapComponent oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.selectedDelivery != widget.selectedDelivery) {
|
|
_updateDestination();
|
|
// Sync navigation state with actual SDK state
|
|
_syncNavigationState();
|
|
|
|
// 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<void> _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');
|
|
}
|
|
}
|
|
|
|
/// Sync local navigation state with actual SDK navigation state
|
|
Future<void> _syncNavigationState() async {
|
|
try {
|
|
final bool isActuallyNavigating = await GoogleMapsNavigator.isGuidanceRunning();
|
|
if (mounted && !_isDisposed) {
|
|
if (_isNavigating != isActuallyNavigating) {
|
|
debugPrint('State mismatch detected! Local: $_isNavigating, SDK: $isActuallyNavigating');
|
|
setState(() {
|
|
_isNavigating = isActuallyNavigating;
|
|
});
|
|
debugPrint('Navigation state synced to: $_isNavigating');
|
|
} else {
|
|
debugPrint('Navigation state already in sync: $_isNavigating');
|
|
}
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Failed to sync navigation state: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _initializeNavigation() async {
|
|
if (_isInitializing || _isSessionInitialized) {
|
|
debugPrint('Skipping initialization: initializing=$_isInitializing, initialized=$_isSessionInitialized');
|
|
return;
|
|
}
|
|
|
|
debugPrint('Initializing navigation session...');
|
|
|
|
setState(() {
|
|
_isInitializing = true;
|
|
});
|
|
|
|
// Safety timeout to reset flag if initialization takes too long
|
|
Future.delayed(const Duration(seconds: 15), () {
|
|
if (mounted && _isInitializing && !_isSessionInitialized) {
|
|
debugPrint('Initialization timeout - resetting flag');
|
|
setState(() {
|
|
_isInitializing = false;
|
|
});
|
|
}
|
|
});
|
|
|
|
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) {
|
|
final lat = address!.latitude!;
|
|
final lon = address.longitude!;
|
|
setState(() {
|
|
_destinationLocation = LatLng(
|
|
latitude: lat,
|
|
longitude: lon,
|
|
);
|
|
});
|
|
debugPrint('Destination set to: $lat, $lon for delivery: ${widget.selectedDelivery!.name}');
|
|
// Just store the destination, don't move camera
|
|
// The navigation will handle camera positioning
|
|
} else {
|
|
debugPrint('Delivery ${widget.selectedDelivery!.name} has no valid address');
|
|
}
|
|
} else {
|
|
debugPrint('No delivery selected, clearing destination');
|
|
setState(() {
|
|
_destinationLocation = null;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _applyDarkModeStyle() async {
|
|
// Check if widget is still mounted and controller exists
|
|
if (!mounted || _navigationController == null || _isDisposed || !_isMapViewReady) return;
|
|
|
|
try {
|
|
if (!mounted || _isDisposed) return;
|
|
|
|
// Get current theme brightness
|
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
|
|
|
// Force night mode based on theme
|
|
await _navigationController!.setForceNightMode(
|
|
isDarkMode ? NavigationForceNightMode.forceNight : NavigationForceNightMode.forceDay,
|
|
);
|
|
debugPrint('Night mode set to: ${isDarkMode ? 'night' : 'day'}');
|
|
|
|
// 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<void> _toggleAudioGuidance() async {
|
|
if (_isDisposed) return;
|
|
|
|
try {
|
|
final newState = !_isAudioGuidanceEnabled;
|
|
await GoogleMapsNavigator.setAudioGuidance(
|
|
NavigationAudioGuidanceSettings(
|
|
guidanceType: newState
|
|
? NavigationAudioGuidanceType.alertsAndGuidance
|
|
: NavigationAudioGuidanceType.silent,
|
|
),
|
|
);
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_isAudioGuidanceEnabled = newState;
|
|
});
|
|
debugPrint('Audio guidance ${newState ? 'enabled' : 'disabled'}');
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Error toggling audio guidance: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _startNavigation() async {
|
|
if (_destinationLocation == null) {
|
|
debugPrint('Cannot start navigation: no destination set');
|
|
return;
|
|
}
|
|
|
|
debugPrint('Starting navigation to: $_destinationLocation');
|
|
|
|
// Check if map is ready before attempting to start
|
|
if (!_isMapViewReady) {
|
|
debugPrint('Map not ready yet - navigation will start automatically when ready');
|
|
if (mounted) {
|
|
setState(() {
|
|
_pendingNavigationStart = true;
|
|
_isStartingNavigation = true;
|
|
_loadingMessage = 'Waiting for map to load...';
|
|
});
|
|
ToastHelper.showInfo(context, 'Map is loading. Navigation will start automatically...');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Show loading indicator
|
|
if (mounted) {
|
|
setState(() {
|
|
_isStartingNavigation = true;
|
|
_loadingMessage = 'Starting navigation...';
|
|
});
|
|
}
|
|
|
|
// Safety timeout to reset flag if navigation start takes too long
|
|
Future.delayed(const Duration(seconds: 10), () {
|
|
if (mounted && _isStartingNavigation) {
|
|
debugPrint('Navigation start timeout - resetting flag');
|
|
setState(() {
|
|
_isStartingNavigation = false;
|
|
});
|
|
ToastHelper.showError(context, 'Navigation start timed out. Please try again.');
|
|
}
|
|
});
|
|
|
|
bool navigationStarted = false;
|
|
|
|
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) {
|
|
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;
|
|
_pendingNavigationStart = false; // Clear pending flag on success
|
|
});
|
|
}
|
|
} catch (e) {
|
|
final errorMessage = _formatErrorMessage(e);
|
|
debugPrint('Navigation start error: $errorMessage');
|
|
debugPrint('Full error: $e');
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_isStartingNavigation = false;
|
|
_pendingNavigationStart = false; // Clear pending flag on error
|
|
});
|
|
|
|
ToastHelper.showError(context, 'Navigation error: $errorMessage', duration: const Duration(seconds: 4));
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _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();
|
|
|
|
// Wait a moment for the SDK to fully stop
|
|
await Future.delayed(const Duration(milliseconds: 200));
|
|
|
|
// Verify navigation actually stopped by checking SDK state
|
|
final bool isStillRunning = await GoogleMapsNavigator.isGuidanceRunning();
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_isNavigating = isStillRunning;
|
|
_pendingNavigationStart = false; // Clear pending flag when stopping
|
|
});
|
|
debugPrint('Navigation stopped, state synced: $_isNavigating');
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
debugPrint('Navigation stop error: $e');
|
|
setState(() {
|
|
_pendingNavigationStart = false; // Clear pending flag on error
|
|
});
|
|
// Even on error, try to sync state
|
|
await _syncNavigationState();
|
|
}
|
|
}
|
|
}
|
|
|
|
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');
|
|
}
|
|
}
|
|
|
|
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 Trois-Rivières if not available)
|
|
final initialPosition = const LatLng(latitude: 46.33857534324389, longitude: -72.60787418369715);
|
|
|
|
// Get safe area insets to avoid rounded corners and notches
|
|
final bottomSafeArea = MediaQuery.of(context).padding.bottom;
|
|
|
|
// Calculate dynamic padding for bottom button bar
|
|
// Must account for full button container height to prevent map showing underneath
|
|
// Button container height = top padding (8) + button height (~44) + bottom padding (bottomSafeArea + 8)
|
|
final topPadding = 0.0;
|
|
final bottomPadding = 100.0; // Full height of button container to properly cut off map
|
|
|
|
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;
|
|
// Disable navigation trip progress bar
|
|
await controller.setNavigationTripProgressBarEnabled(false);
|
|
if (!mounted || _isDisposed) return;
|
|
// Enable recenter button
|
|
await controller.setRecenterButtonEnabled(true);
|
|
if (!mounted || _isDisposed) return;
|
|
// Enable speed limit icon
|
|
await controller.setSpeedLimitIconEnabled(true);
|
|
if (!mounted || _isDisposed) return;
|
|
// Enable traffic incident cards
|
|
await controller.setTrafficIncidentCardsEnabled(true);
|
|
if (!mounted || _isDisposed) return;
|
|
// Disable traffic prompts
|
|
await controller.setTrafficPromptsEnabled(false);
|
|
if (!mounted || _isDisposed) return;
|
|
// Disable report incident button
|
|
await controller.setReportIncidentButtonEnabled(false);
|
|
if (!mounted || _isDisposed) return;
|
|
// Enable speedometer
|
|
await controller.setSpeedometerEnabled(true);
|
|
if (!mounted || _isDisposed) return;
|
|
// Set audio guidance to silent by default
|
|
await GoogleMapsNavigator.setAudioGuidance(
|
|
NavigationAudioGuidanceSettings(
|
|
guidanceType: NavigationAudioGuidanceType.silent,
|
|
),
|
|
);
|
|
debugPrint('Navigation UI elements configured');
|
|
|
|
// 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();
|
|
|
|
// Immediately follow user location on map initialization
|
|
try {
|
|
if (mounted && _navigationController != null && _isMapViewReady && !_isDisposed) {
|
|
// Start following user location immediately
|
|
await _recenterMap();
|
|
debugPrint('Map initialized following user location');
|
|
// Sync navigation state to ensure button reflects actual navigation state
|
|
await _syncNavigationState();
|
|
|
|
// Auto-start navigation if user requested it before map was ready
|
|
if (_pendingNavigationStart && !_isNavigating && !_isDisposed && mounted) {
|
|
debugPrint('Auto-starting navigation as requested by user');
|
|
_pendingNavigationStart = false;
|
|
await _startNavigation();
|
|
}
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Follow location 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 _recenterMap();
|
|
debugPrint('Map initialized following user location (retry)');
|
|
// Sync navigation state to ensure button reflects actual navigation state
|
|
await _syncNavigationState();
|
|
|
|
// Auto-start navigation if user requested it before map was ready
|
|
if (_pendingNavigationStart && !_isNavigating && !_isDisposed && mounted) {
|
|
debugPrint('Auto-starting navigation as requested by user (after retry)');
|
|
_pendingNavigationStart = false;
|
|
await _startNavigation();
|
|
}
|
|
} catch (e2) {
|
|
debugPrint('Follow location 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: EdgeInsets.only(
|
|
left: 12,
|
|
right: 12,
|
|
top: 8,
|
|
// Add safe area padding plus extra margin to avoid rounded corners
|
|
bottom: bottomSafeArea > 0 ? bottomSafeArea + 8 : 16,
|
|
),
|
|
child: Builder(
|
|
builder: (context) {
|
|
final isMobile = context.isMobile;
|
|
final showButtonLabels = !isMobile;
|
|
|
|
return Row(
|
|
children: [
|
|
// Start button
|
|
Expanded(
|
|
child: _buildBottomActionButton(
|
|
label: _isNavigating ? 'Stop' : 'Start',
|
|
icon: _isNavigating ? Icons.stop : Icons.navigation,
|
|
onPressed: _isStartingNavigation || _isInitializing || (!_isMapViewReady && !_isNavigating) || (widget.selectedDelivery == null && !_isNavigating)
|
|
? null
|
|
: (_isNavigating ? _stopNavigation : _startNavigation),
|
|
isDanger: _isNavigating,
|
|
showLabel: showButtonLabels,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
// Photo button (disabled when no delivery selected or warehouse delivery)
|
|
Expanded(
|
|
child: _buildBottomActionButton(
|
|
label: 'Photo',
|
|
icon: Icons.camera_alt,
|
|
onPressed: widget.selectedDelivery != null && !widget.selectedDelivery!.isWarehouseDelivery
|
|
? () => widget.onAction?.call('photo')
|
|
: null,
|
|
showLabel: showButtonLabels,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
// Note button (only enabled if delivery has notes and not warehouse)
|
|
Expanded(
|
|
child: _buildBottomActionButton(
|
|
label: 'Note',
|
|
icon: Icons.note_add,
|
|
onPressed: _hasNotes() && widget.selectedDelivery != null && !widget.selectedDelivery!.isWarehouseDelivery
|
|
? () => widget.onAction?.call('note')
|
|
: null,
|
|
showLabel: showButtonLabels,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
// Completed button (disabled for warehouse delivery)
|
|
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.selectedDelivery!.isWarehouseDelivery
|
|
? () => widget.onAction?.call(
|
|
widget.selectedDelivery!.delivered ? 'uncomplete' : 'complete',
|
|
)
|
|
: null,
|
|
isPrimary: widget.selectedDelivery != null && !widget.selectedDelivery!.delivered && !widget.selectedDelivery!.isWarehouseDelivery,
|
|
showLabel: showButtonLabels,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
// Audio guidance toggle button - positioned below green navigation card
|
|
Positioned(
|
|
top: topPadding + 48,
|
|
right: 16,
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surface,
|
|
borderRadius: BorderRadius.circular(28),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.2),
|
|
blurRadius: 8,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: IconButton(
|
|
icon: Icon(
|
|
_isAudioGuidanceEnabled ? Icons.volume_up : Icons.volume_off,
|
|
color: _isAudioGuidanceEnabled
|
|
? Theme.of(context).colorScheme.primary
|
|
: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
onPressed: _toggleAudioGuidance,
|
|
tooltip: _isAudioGuidanceEnabled ? 'Mute navigation' : 'Unmute navigation',
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// 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,
|
|
bool showLabel = true,
|
|
}) {
|
|
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: EdgeInsets.symmetric(
|
|
horizontal: showLabel ? 8 : 12,
|
|
vertical: showLabel ? 10 : 12,
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
icon,
|
|
color: textColor,
|
|
size: showLabel ? 18 : 20,
|
|
),
|
|
if (showLabel) ...[
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: textColor,
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
}
|