Implement Google Navigation Flutter integration for turn-by-turn delivery navigation

Adds complete Google Navigation support with:
- LocationPermissionService for runtime location permissions
- NavigationSessionService for session and route management
- NavigationPage for full-screen turn-by-turn navigation UI
- NavigationTermsAndConditionsDialog for service acceptance
- Comprehensive i18n support (English/French)
- Android minSdk=23 with Java NIO desugaring
- iOS location permissions in Info.plist
- Error handling with user-friendly dialogs
- Location update and arrival notifications

Includes detailed setup guide and implementation documentation with API key
configuration instructions, integration examples, and testing checklist.

Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jean-Philippe Brule
2025-11-15 20:43:29 -05:00
parent 5714fd8443
commit 46af8f55a2
10 changed files with 1312 additions and 3 deletions
+82
View File
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class NavigationTermsAndConditionsDialog extends StatelessWidget {
final VoidCallback onAccept;
final VoidCallback? onDecline;
const NavigationTermsAndConditionsDialog({
Key? key,
required this.onAccept,
this.onDecline,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final colorScheme = Theme.of(context).colorScheme;
return AlertDialog(
title: Text(
l10n?.navigationTcTitle ?? 'Navigation Service',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: colorScheme.onSurface,
),
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n?.navigationTcDescription ??
'This app uses Google Navigation to provide turn-by-turn navigation for deliveries.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface,
),
),
const SizedBox(height: 16),
Text(
l10n?.navigationTcAttribution ??
'Attribution: Maps and navigation services provided by Google Maps.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
Text(
l10n?.navigationTcTerms ??
'By accepting, you agree to Google\'s Terms of Service and Privacy Policy for Navigation services.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
actions: [
if (onDecline != null)
TextButton(
onPressed: () {
Navigator.of(context).pop();
onDecline!();
},
child: Text(
l10n?.decline ?? 'Decline',
style: TextStyle(color: colorScheme.error),
),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
onAccept();
},
child: Text(
l10n?.accept ?? 'Accept',
style: TextStyle(color: colorScheme.primary),
),
),
],
);
}
}
+19 -1
View File
@@ -65,5 +65,23 @@
"completed": {"type": "int"},
"total": {"type": "int"}
}
}
},
"navigationTcTitle": "Navigation Service",
"navigationTcDescription": "This app uses Google Navigation to provide turn-by-turn navigation for deliveries.",
"navigationTcAttribution": "Attribution: Maps and navigation services provided by Google Maps.",
"navigationTcTerms": "By accepting, you agree to Google's Terms of Service and Privacy Policy for Navigation services.",
"accept": "Accept",
"decline": "Decline",
"locationPermissionRequired": "Location Permission",
"locationPermissionMessage": "This app requires location permission to navigate to deliveries.",
"locationPermissionDenied": "Location permission denied. Navigation cannot proceed.",
"permissionPermanentlyDenied": "Permission Required",
"openSettingsMessage": "Location permission is permanently denied. Please enable it in app settings.",
"openSettings": "Open Settings",
"cancel": "Cancel",
"ok": "OK",
"requestPermission": "Request Permission",
"navigationArrived": "You have arrived at the destination",
"navigatingTo": "Navigating to",
"initializingNavigation": "Initializing navigation..."
}
+19 -1
View File
@@ -65,5 +65,23 @@
"completed": {"type": "int"},
"total": {"type": "int"}
}
}
},
"navigationTcTitle": "Service de Navigation",
"navigationTcDescription": "Cette application utilise Google Navigation pour fournir une navigation virage par virage pour les livraisons.",
"navigationTcAttribution": "Attribution: Services de cartes et de navigation fournis par Google Maps.",
"navigationTcTerms": "En acceptant, vous acceptez les conditions d'utilisation et la politique de confidentialit de Google pour les services de navigation.",
"accept": "Accepter",
"decline": "Refuser",
"locationPermissionRequired": "Permission de localisation",
"locationPermissionMessage": "Cette application ncessite la permission de localisation pour naviguer vers les livraisons.",
"locationPermissionDenied": "Permission de localisation refuse. La navigation ne peut pas continuer.",
"permissionPermanentlyDenied": "Permission requise",
"openSettingsMessage": "La permission de localisation est dfinitivement refuse. Veuillez l'activer dans les paramtres de l'application.",
"openSettings": "Ouvrir les paramtres",
"cancel": "Annuler",
"ok": "OK",
"requestPermission": "Demander la permission",
"navigationArrived": "Vous tes arriv la destination",
"navigatingTo": "Navigation vers",
"initializingNavigation": "Initialisation de la navigation..."
}
+311
View File
@@ -0,0 +1,311 @@
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> {
late GoogleMapsNavigationViewController _navigationViewController;
late LocationPermissionService _permissionService;
bool _isNavigationInitialized = false;
bool _hasLocationPermission = false;
@override
void initState() {
super.initState();
_permissionService = LocationPermissionService();
_initializeNavigation();
}
Future<void> _initializeNavigation() async {
try {
final hasPermission = await _permissionService.hasLocationPermission();
if (!hasPermission) {
if (mounted) {
_showPermissionDialog();
}
return;
}
setState(() {
_hasLocationPermission = true;
});
if (mounted) {
_initializeNavigationSession();
}
} catch (e) {
if (mounted) {
_showErrorDialog('Initialization error: ${e.toString()}');
}
}
}
Future<void> _initializeNavigationSession() async {
try {
await GoogleMapsNavigationViewController.initializeNavigationSession();
if (mounted) {
setState(() {
_isNavigationInitialized = true;
});
// Set destination after session is initialized
await _setDestination();
}
} catch (e) {
if (mounted) {
_showErrorDialog('Failed to initialize navigation: ${e.toString()}');
}
}
}
Future<void> _setDestination() async {
try {
final destination = NavigationDisplayOptions(
showDestinationMarkers: true,
);
final waypoint = Waypoint(
title: widget.delivery.name,
target: LatLng(
latitude: widget.destinationLatitude,
longitude: widget.destinationLongitude,
),
);
await _navigationViewController.setDestinations(
destinations: [waypoint],
displayOptions: destination,
);
// Listen for location updates
_navigationViewController.addOnLocationUpdatedListener((location) {
debugPrint(
'Location updated: ${location.latitude}, ${location.longitude}',
);
});
// Listen for navigation events
_navigationViewController.addOnNavigationUIEnabledListener((isEnabled) {
debugPrint('Navigation UI enabled: $isEnabled');
});
// Listen for waypoint reached
_navigationViewController.addOnArrivalListener((arrival) {
_handleArrival(arrival);
});
} catch (e) {
if (mounted) {
_showErrorDialog('Failed to set destination: ${e.toString()}');
}
}
}
void _handleArrival(NavInfo navInfo) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)?.navigationArrived ??
'You have arrived at the destination',
),
duration: const 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;
});
_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'),
),
],
),
);
}
@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) {
_navigationViewController = controller;
},
initialCameraPosition: CameraPosition(
target: LatLng(
latitude: widget.destinationLatitude,
longitude: widget.destinationLongitude,
),
zoom: 15,
),
useMarkerClusteringForDynamicMarkers: true,
zoomGesturesEnabled: true,
scrollGesturesEnabled: true,
navigationUIEnabled: true,
mapToolbarEnabled: true,
)
: 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();
}
}
@@ -0,0 +1,70 @@
import 'package:permission_handler/permission_handler.dart';
class LocationPermissionService {
static const String _tcKey = 'navigation_tc_accepted';
Future<LocationPermissionResult> requestLocationPermission() async {
final status = await Permission.location.request();
return switch (status) {
PermissionStatus.granted => LocationPermissionResult.granted(),
PermissionStatus.denied => LocationPermissionResult.denied(),
PermissionStatus.permanentlyDenied =>
LocationPermissionResult.permanentlyDenied(),
_ => LocationPermissionResult.error(
message: 'Unexpected permission status: $status',
),
};
}
Future<bool> hasLocationPermission() async {
final status = await Permission.location.status;
return status.isGranted;
}
Future<void> openAppSettings() async {
await openAppSettings();
}
}
sealed class LocationPermissionResult {
const LocationPermissionResult();
factory LocationPermissionResult.granted() => _Granted();
factory LocationPermissionResult.denied() => _Denied();
factory LocationPermissionResult.permanentlyDenied() =>
_PermanentlyDenied();
factory LocationPermissionResult.error({required String message}) =>
_Error(message);
R when<R>({
required R Function() granted,
required R Function() denied,
required R Function() permanentlyDenied,
required R Function(String message) error,
}) {
return switch (this) {
_Granted() => granted(),
_Denied() => denied(),
_PermanentlyDenied() => permanentlyDenied(),
_Error(:final message) => error(message),
};
}
}
final class _Granted extends LocationPermissionResult {
const _Granted();
}
final class _Denied extends LocationPermissionResult {
const _Denied();
}
final class _PermanentlyDenied extends LocationPermissionResult {
const _PermanentlyDenied();
}
final class _Error extends LocationPermissionResult {
final String message;
const _Error(this.message);
}
@@ -0,0 +1,184 @@
import 'package:google_navigation_flutter/google_navigation_flutter.dart';
class NavigationSessionService {
static final NavigationSessionService _instance =
NavigationSessionService._internal();
factory NavigationSessionService() {
return _instance;
}
NavigationSessionService._internal();
bool _isSessionInitialized = false;
GoogleMapsNavigationViewController? _controller;
bool get isSessionInitialized => _isSessionInitialized;
Future<void> initializeSession() async {
if (_isSessionInitialized) {
return;
}
try {
await GoogleMapsNavigationViewController.initializeNavigationSession();
_isSessionInitialized = true;
} catch (e) {
throw NavigationSessionException('Failed to initialize session: $e');
}
}
Future<void> setController(
GoogleMapsNavigationViewController controller,
) async {
_controller = controller;
}
Future<NavigationRoute> calculateRoute({
required double startLatitude,
required double startLongitude,
required double destinationLatitude,
required double destinationLongitude,
}) async {
if (!_isSessionInitialized) {
throw NavigationSessionException('Session not initialized');
}
if (_controller == null) {
throw NavigationSessionException('Controller not set');
}
try {
final origin = LatLng(
latitude: startLatitude,
longitude: startLongitude,
);
final destination = LatLng(
latitude: destinationLatitude,
longitude: destinationLongitude,
);
final waypoint = Waypoint(
target: destination,
);
// Set destinations will trigger route calculation
await _controller!.setDestinations(
destinations: [waypoint],
);
return NavigationRoute(
startLocation: origin,
endLocation: destination,
isCalculated: true,
);
} catch (e) {
throw NavigationSessionException('Failed to calculate route: $e');
}
}
Future<void> startNavigation() async {
if (!_isSessionInitialized || _controller == null) {
throw NavigationSessionException('Navigation not properly initialized');
}
try {
await _controller!.startGuidance();
} catch (e) {
throw NavigationSessionException('Failed to start navigation: $e');
}
}
Future<void> stopNavigation() async {
if (_controller == null) {
return;
}
try {
await _controller!.stopGuidance();
} catch (e) {
throw NavigationSessionException('Failed to stop navigation: $e');
}
}
void addLocationListener(
Function(LatLng location) onLocationUpdate,
) {
if (_controller == null) {
throw NavigationSessionException('Controller not set');
}
_controller!.addOnLocationUpdatedListener((location) {
onLocationUpdate(location);
});
}
void addArrivalListener(Function(NavInfo info) onArrival) {
if (_controller == null) {
throw NavigationSessionException('Controller not set');
}
_controller!.addOnArrivalListener((info) {
onArrival(info);
});
}
void addRemainingDistanceListener(
Function(int distanceMeters) onDistanceChange,
) {
if (_controller == null) {
throw NavigationSessionException('Controller not set');
}
_controller!.addOnRemainingDistanceChangedListener((distance) {
onDistanceChange(distance);
});
}
void clearAllListeners() {
if (_controller == null) {
return;
}
_controller!.removeAllListeners();
}
Future<void> cleanup() async {
try {
if (_controller != null) {
await stopNavigation();
clearAllListeners();
}
_isSessionInitialized = false;
_controller = null;
} catch (e) {
throw NavigationSessionException('Failed to cleanup: $e');
}
}
}
class NavigationRoute {
final LatLng startLocation;
final LatLng endLocation;
final bool isCalculated;
final int? distanceMeters;
final Duration? estimatedTime;
NavigationRoute({
required this.startLocation,
required this.endLocation,
required this.isCalculated,
this.distanceMeters,
this.estimatedTime,
});
}
class NavigationSessionException implements Exception {
final String message;
NavigationSessionException(this.message);
@override
String toString() => 'NavigationSessionException: $message';
}