MotorSignal-App/lib/services/routes/navigation_handoff.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);
}
}