Add comprehensive theme management system with iOS system integration: - System theme detection: App follows iOS dark/light mode preferences via ThemeMode.system - Theme provider: Centralized theme state management with Riverpod (defaults to dark mode) - Settings toggle: Segmented button UI for Light/Dark/Auto theme selection - iOS system UI: Status bar and navigation bar adapt to current theme brightness Dark mode map styling (Android-ready): - DarkModeMapComponent: Reactive theme change detection with didChangeDependencies - Map style application: Custom dark JSON style for navigation maps - Theme-aware styling: Automatically applies/resets map style on theme changes - Note: Map styling currently Android-only due to iOS SDK limitations Updates: - main.dart: System UI overlay styling for iOS, theme provider integration - settings_page.dart: SegmentedButton theme toggle with icons - providers.dart: themeModeProvider for app-wide theme state - dark_mode_map.dart: Theme reactivity and style application logic - navigation_page.dart: Theme detection infrastructure (prepared for future use) Design philosophy: - Follow system preferences by default for native iOS experience - Manual override available for user preference - Clean separation between Flutter UI theming and native map styling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
445 lines
12 KiB
Dart
445 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:google_navigation_flutter/google_navigation_flutter.dart';
|
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
|
import '../models/delivery.dart';
|
|
import '../services/location_permission_service.dart';
|
|
import '../components/navigation_tc_dialog.dart';
|
|
|
|
class NavigationPage extends ConsumerStatefulWidget {
|
|
final Delivery delivery;
|
|
final double destinationLatitude;
|
|
final double destinationLongitude;
|
|
final VoidCallback? onNavigationComplete;
|
|
final VoidCallback? onNavigationCancelled;
|
|
|
|
const NavigationPage({
|
|
super.key,
|
|
required this.delivery,
|
|
required this.destinationLatitude,
|
|
required this.destinationLongitude,
|
|
this.onNavigationComplete,
|
|
this.onNavigationCancelled,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<NavigationPage> createState() => _NavigationPageState();
|
|
}
|
|
|
|
class _NavigationPageState extends ConsumerState<NavigationPage> {
|
|
GoogleNavigationViewController? _navigationViewController;
|
|
late LocationPermissionService _permissionService;
|
|
bool _isNavigationInitialized = false;
|
|
bool _hasLocationPermission = false;
|
|
bool _isControllerReady = false;
|
|
Brightness? _lastBrightness;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_permissionService = LocationPermissionService();
|
|
_initializeNavigation();
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
|
|
// Detect theme changes and reapply map style
|
|
final currentBrightness = Theme.of(context).brightness;
|
|
if (_lastBrightness != null &&
|
|
_lastBrightness != currentBrightness &&
|
|
_isControllerReady) {
|
|
_applyDarkModeStyle();
|
|
}
|
|
_lastBrightness = currentBrightness;
|
|
}
|
|
|
|
Future<void> _initializeNavigation() async {
|
|
try {
|
|
final hasPermission = await _permissionService.hasLocationPermission();
|
|
|
|
if (!hasPermission) {
|
|
if (mounted) {
|
|
await _requestLocationPermission();
|
|
}
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_hasLocationPermission = true;
|
|
_isNavigationInitialized = true;
|
|
});
|
|
} catch (e) {
|
|
if (mounted) {
|
|
_showErrorDialog('Initialization error: ${e.toString()}');
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _initializeNavigationSession() async {
|
|
try {
|
|
await GoogleMapsNavigator.initializeNavigationSession();
|
|
} catch (e) {
|
|
debugPrint('Navigation session initialization error: $e');
|
|
// Don't show error dialog, just log it
|
|
// The session might already be initialized
|
|
}
|
|
}
|
|
|
|
Future<void> _setDestination() async {
|
|
try {
|
|
final waypoint = NavigationWaypoint.withLatLngTarget(
|
|
title: widget.delivery.name,
|
|
target: LatLng(
|
|
latitude: widget.destinationLatitude,
|
|
longitude: widget.destinationLongitude,
|
|
),
|
|
);
|
|
|
|
final destinations = Destinations(
|
|
waypoints: [waypoint],
|
|
displayOptions: NavigationDisplayOptions(showDestinationMarkers: true),
|
|
);
|
|
|
|
await GoogleMapsNavigator.setDestinations(destinations);
|
|
|
|
// Start guidance automatically
|
|
await GoogleMapsNavigator.startGuidance();
|
|
|
|
// Reapply dark mode style after navigation starts
|
|
if (mounted) {
|
|
await _applyDarkModeStyle();
|
|
}
|
|
|
|
// Listen for arrival events
|
|
GoogleMapsNavigator.setOnArrivalListener((event) {
|
|
if (mounted) {
|
|
_handleArrival(event);
|
|
}
|
|
});
|
|
} catch (e) {
|
|
if (mounted) {
|
|
_showErrorDialog('Failed to set destination: ${e.toString()}');
|
|
}
|
|
}
|
|
}
|
|
|
|
void _handleArrival(OnArrivalEvent event) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('You have arrived at the destination'),
|
|
duration: Duration(seconds: 3),
|
|
),
|
|
);
|
|
|
|
// Call completion callback
|
|
widget.onNavigationComplete?.call();
|
|
}
|
|
}
|
|
|
|
void _showPermissionDialog() {
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(l10n?.locationPermissionRequired ?? 'Location Permission'),
|
|
content: Text(
|
|
l10n?.locationPermissionMessage ??
|
|
'This app requires location permission to navigate to deliveries.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.of(context).pop();
|
|
widget.onNavigationCancelled?.call();
|
|
},
|
|
child: Text(l10n?.cancel ?? 'Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.of(context).pop();
|
|
_requestLocationPermission();
|
|
},
|
|
child: Text(l10n?.requestPermission ?? 'Request Permission'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _requestLocationPermission() async {
|
|
final result = await _permissionService.requestLocationPermission();
|
|
|
|
if (!mounted) return;
|
|
|
|
result.when(
|
|
granted: () {
|
|
setState(() {
|
|
_hasLocationPermission = true;
|
|
_isNavigationInitialized = true;
|
|
});
|
|
_initializeNavigationSession();
|
|
},
|
|
denied: () {
|
|
_showErrorDialog(
|
|
AppLocalizations.of(context)?.locationPermissionDenied ??
|
|
'Location permission denied. Navigation cannot proceed.',
|
|
);
|
|
widget.onNavigationCancelled?.call();
|
|
},
|
|
permanentlyDenied: () {
|
|
_showPermissionSettingsDialog();
|
|
},
|
|
error: (message) {
|
|
_showErrorDialog(message);
|
|
widget.onNavigationCancelled?.call();
|
|
},
|
|
);
|
|
}
|
|
|
|
void _showPermissionSettingsDialog() {
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(l10n?.permissionPermanentlyDenied ?? 'Permission Required'),
|
|
content: Text(
|
|
l10n?.openSettingsMessage ??
|
|
'Location permission is permanently denied. Please enable it in app settings.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: Text(l10n?.cancel ?? 'Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
_permissionService.openAppSettings();
|
|
Navigator.of(context).pop();
|
|
},
|
|
child: Text(l10n?.openSettings ?? 'Open Settings'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showErrorDialog(String message) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(AppLocalizations.of(context)?.error ?? 'Error'),
|
|
content: Text(message),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.of(context).pop();
|
|
widget.onNavigationCancelled?.call();
|
|
},
|
|
child: Text(AppLocalizations.of(context)?.ok ?? 'OK'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _applyDarkModeStyle() async {
|
|
if (_navigationViewController == null || !_isControllerReady) return;
|
|
|
|
try {
|
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
|
if (isDarkMode) {
|
|
await _navigationViewController!.setMapStyle(_getDarkMapStyle());
|
|
} else {
|
|
await _navigationViewController!.setMapStyle(null);
|
|
}
|
|
} catch (e) {
|
|
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"}]
|
|
}
|
|
]''';
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(
|
|
'${l10n?.navigatingTo ?? 'Navigating to'}: ${widget.delivery.name}',
|
|
),
|
|
elevation: 0,
|
|
),
|
|
body: _hasLocationPermission && _isNavigationInitialized
|
|
? GoogleMapsNavigationView(
|
|
onViewCreated: (controller) async {
|
|
_navigationViewController = controller;
|
|
_isControllerReady = true;
|
|
await _initializeNavigationSession();
|
|
await Future.delayed(const Duration(milliseconds: 500));
|
|
await _applyDarkModeStyle();
|
|
await _setDestination();
|
|
},
|
|
initialCameraPosition: CameraPosition(
|
|
target: LatLng(
|
|
latitude: widget.destinationLatitude,
|
|
longitude: widget.destinationLongitude,
|
|
),
|
|
zoom: 15,
|
|
),
|
|
)
|
|
: Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const CircularProgressIndicator(),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
l10n?.initializingNavigation ??
|
|
'Initializing navigation...',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
floatingActionButton: _hasLocationPermission && _isNavigationInitialized
|
|
? FloatingActionButton(
|
|
onPressed: () {
|
|
widget.onNavigationCancelled?.call();
|
|
Navigator.of(context).pop();
|
|
},
|
|
child: const Icon(Icons.close),
|
|
)
|
|
: null,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
super.dispose();
|
|
}
|
|
}
|