283 lines
10 KiB
Dart
283 lines
10 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import 'route_models.dart';
|
|
|
|
/// Target navigation application for handoff.
|
|
enum NavigationApp {
|
|
googleMaps,
|
|
waze,
|
|
platformDefault;
|
|
|
|
String get label {
|
|
switch (this) {
|
|
case NavigationApp.googleMaps:
|
|
return 'Google Maps';
|
|
case NavigationApp.waze:
|
|
return 'Waze';
|
|
case NavigationApp.platformDefault:
|
|
return 'System Map';
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Service handling truthful handoff to external navigation applications.
|
|
/// MotorSignal delegates road-following turn-by-turn navigation to trusted
|
|
/// navigation apps without claiming internal turn-by-turn guidance.
|
|
class NavigationHandoffService {
|
|
const NavigationHandoffService();
|
|
|
|
/// Builds a standard Google Maps turn-by-turn navigation URI.
|
|
static Uri buildGoogleMapsUri({
|
|
required double latitude,
|
|
required double longitude,
|
|
String? displayName,
|
|
}) {
|
|
final dest = '${latitude.toStringAsFixed(6)},${longitude.toStringAsFixed(6)}';
|
|
return Uri.parse('https://www.google.com/maps/dir/?api=1&destination=$dest');
|
|
}
|
|
|
|
/// Builds a standard Waze navigation URI.
|
|
static Uri buildWazeUri({
|
|
required double latitude,
|
|
required double longitude,
|
|
}) {
|
|
final ll = '${latitude.toStringAsFixed(6)},${longitude.toStringAsFixed(6)}';
|
|
return Uri.parse('https://waze.com/ul?ll=$ll&navigate=yes');
|
|
}
|
|
|
|
/// Builds a standard RFC 5870 system geo: navigation URI.
|
|
static Uri buildSystemGeoUri({
|
|
required double latitude,
|
|
required double longitude,
|
|
required String displayName,
|
|
}) {
|
|
final coords = '${latitude.toStringAsFixed(6)},${longitude.toStringAsFixed(6)}';
|
|
return Uri.parse('geo:$coords?q=$coords($displayName)');
|
|
}
|
|
|
|
/// Displays an interactive navigation handoff bottom sheet with direct links
|
|
/// to Google Maps, Waze, and native system mapping.
|
|
static void showHandoffSheet({
|
|
required BuildContext context,
|
|
required double latitude,
|
|
required double longitude,
|
|
required String displayName,
|
|
}) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
backgroundColor: const Color(0xFF1B1E26),
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
|
),
|
|
builder: (sheetContext) {
|
|
return SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.navigation, color: Color(0xFF00E5FF), size: 22),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'NAVIGATION HANDOFF',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
letterSpacing: 1.2,
|
|
fontWeight: FontWeight.w700,
|
|
color: Color(0xFF00E5FF),
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
displayName,
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.white,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 20),
|
|
ListTile(
|
|
leading: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white10,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: const Icon(Icons.map, color: Colors.white),
|
|
),
|
|
title: const Text('Google Maps', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600)),
|
|
subtitle: const Text('Open turn-by-turn in Google Maps', style: TextStyle(color: Colors.white54, fontSize: 12)),
|
|
trailing: const Icon(Icons.open_in_new, color: Colors.white38, size: 18),
|
|
onTap: () async {
|
|
Navigator.pop(sheetContext);
|
|
final uri = buildGoogleMapsUri(
|
|
latitude: latitude,
|
|
longitude: longitude,
|
|
displayName: displayName,
|
|
);
|
|
if (await canLaunchUrl(uri)) {
|
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
}
|
|
},
|
|
),
|
|
const Divider(color: Colors.white12, height: 1),
|
|
ListTile(
|
|
leading: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white10,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: const Icon(Icons.directions_car, color: Color(0xFF00E5FF)),
|
|
),
|
|
title: const Text('Waze', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600)),
|
|
subtitle: const Text('Open road directions in Waze', style: TextStyle(color: Colors.white54, fontSize: 12)),
|
|
trailing: const Icon(Icons.open_in_new, color: Colors.white38, size: 18),
|
|
onTap: () async {
|
|
Navigator.pop(sheetContext);
|
|
final uri = buildWazeUri(
|
|
latitude: latitude,
|
|
longitude: longitude,
|
|
);
|
|
if (await canLaunchUrl(uri)) {
|
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
}
|
|
},
|
|
),
|
|
const Divider(color: Colors.white12, height: 1),
|
|
ListTile(
|
|
leading: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white10,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: const Icon(Icons.place, color: Colors.white70),
|
|
),
|
|
title: const Text('System Map', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600)),
|
|
subtitle: const Text('Open in default platform mapping app', style: TextStyle(color: Colors.white54, fontSize: 12)),
|
|
trailing: const Icon(Icons.open_in_new, color: Colors.white38, size: 18),
|
|
onTap: () async {
|
|
Navigator.pop(sheetContext);
|
|
final uri = buildSystemGeoUri(
|
|
latitude: latitude,
|
|
longitude: longitude,
|
|
displayName: displayName,
|
|
);
|
|
if (await canLaunchUrl(uri)) {
|
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
}
|
|
},
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Launches external navigation to a single target [stop].
|
|
Future<bool> launchNavigationToStop({
|
|
required RouteStop stop,
|
|
NavigationApp app = NavigationApp.googleMaps,
|
|
}) async {
|
|
Uri uri;
|
|
switch (app) {
|
|
case NavigationApp.googleMaps:
|
|
uri = buildGoogleMapsUri(
|
|
latitude: stop.latitude,
|
|
longitude: stop.longitude,
|
|
displayName: stop.displayName,
|
|
);
|
|
break;
|
|
|
|
case NavigationApp.waze:
|
|
uri = buildWazeUri(
|
|
latitude: stop.latitude,
|
|
longitude: stop.longitude,
|
|
);
|
|
break;
|
|
|
|
case NavigationApp.platformDefault:
|
|
uri = buildSystemGeoUri(
|
|
latitude: stop.latitude,
|
|
longitude: stop.longitude,
|
|
displayName: stop.displayName,
|
|
);
|
|
break;
|
|
}
|
|
|
|
try {
|
|
if (await canLaunchUrl(uri)) {
|
|
return await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
}
|
|
final fallbackUri = Uri.parse(
|
|
'https://www.google.com/maps/search/?api=1&query=${stop.latitude},${stop.longitude}',
|
|
);
|
|
if (await canLaunchUrl(fallbackUri)) {
|
|
return await launchUrl(fallbackUri, mode: LaunchMode.externalApplication);
|
|
}
|
|
return false;
|
|
} catch (e) {
|
|
debugPrint('[NavigationHandoffService] Failed to launch navigation: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Launches multi-stop road route navigation in external maps (up to provider limit).
|
|
Future<bool> launchFullRouteNavigation({
|
|
required MotorSignalRoute route,
|
|
NavigationApp app = NavigationApp.googleMaps,
|
|
}) async {
|
|
final stops = route.orderedStops;
|
|
if (stops.isEmpty) return false;
|
|
if (stops.length == 1) {
|
|
return launchNavigationToStop(stop: stops.first, app: app);
|
|
}
|
|
|
|
final origin = '${stops.first.latitude},${stops.first.longitude}';
|
|
final dest = '${stops.last.latitude},${stops.last.longitude}';
|
|
|
|
if (app == NavigationApp.googleMaps) {
|
|
final intermediateStops = stops.sublist(1, stops.length - 1);
|
|
final cappedIntermediates = intermediateStops.take(9);
|
|
final waypointsStr = cappedIntermediates
|
|
.map((s) => '${s.latitude},${s.longitude}')
|
|
.join('|');
|
|
|
|
final waypointsParam =
|
|
waypointsStr.isNotEmpty ? '&waypoints=$waypointsStr' : '';
|
|
final uri = Uri.parse(
|
|
'https://www.google.com/maps/dir/?api=1&origin=$origin&destination=$dest$waypointsParam',
|
|
);
|
|
|
|
try {
|
|
if (await canLaunchUrl(uri)) {
|
|
return await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[NavigationHandoffService] Failed to launch multi-stop route: $e');
|
|
}
|
|
}
|
|
|
|
return launchNavigationToStop(stop: stops.last, app: app);
|
|
}
|
|
}
|