import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:latlong2/latlong.dart'; import '../../../map/providers/map_provider.dart'; import '../../../search/data/search_repository.dart'; import '../../providers/routing_provider.dart'; import '../widgets/route_summary.dart'; import '../widgets/step_list.dart'; class RouteScreen extends ConsumerStatefulWidget { final double? destinationLat; final double? destinationLon; final String? destinationName; const RouteScreen({ super.key, this.destinationLat, this.destinationLon, this.destinationName, }); @override ConsumerState createState() => _RouteScreenState(); } class _RouteScreenState extends ConsumerState { @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { _initializeRoute(); }); } void _initializeRoute() { final notifier = ref.read(routingProvider.notifier); final mapState = ref.read(mapProvider); // Set origin from GPS if available, otherwise use the map center. final origin = mapState.currentLocation ?? mapState.center; final originName = mapState.currentLocation != null ? 'My Location' : 'Map center'; notifier.setOrigin(origin.latitude, origin.longitude, originName); // Set destination from route params. if (widget.destinationLat != null && widget.destinationLon != null) { notifier.setDestination( widget.destinationLat!, widget.destinationLon!, widget.destinationName ?? 'Destination', ); notifier.calculateRoute(); } } @override Widget build(BuildContext context) { final routingState = ref.watch(routingProvider); return Scaffold( appBar: AppBar( title: const Text('Route'), ), body: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Origin and destination fields Padding( padding: const EdgeInsets.all(16), child: Column( children: [ _LocationField( label: 'From', icon: Icons.trip_origin, value: routingState.originName, onSelected: (result) { ref.read(routingProvider.notifier).setOrigin( result.latitude, result.longitude, result.name, ); ref.read(routingProvider.notifier).calculateRoute(); }, ), const SizedBox(height: 8), _LocationField( label: 'To', icon: Icons.flag, value: routingState.destName, onSelected: (result) { ref.read(routingProvider.notifier).setDestination( result.latitude, result.longitude, result.name, ); ref.read(routingProvider.notifier).calculateRoute(); }, ), ], ), ), // Profile selector Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ _ProfileChip( icon: Icons.directions_car, label: 'Drive', isSelected: routingState.profile == 'driving', onTap: () { ref.read(routingProvider.notifier).setProfile('driving'); ref.read(routingProvider.notifier).calculateRoute(); }, ), const SizedBox(width: 8), _ProfileChip( icon: Icons.directions_walk, label: 'Walk', isSelected: routingState.profile == 'walking', onTap: () { ref.read(routingProvider.notifier).setProfile('walking'); ref.read(routingProvider.notifier).calculateRoute(); }, ), const SizedBox(width: 8), _ProfileChip( icon: Icons.directions_bike, label: 'Cycle', isSelected: routingState.profile == 'cycling', onTap: () { ref.read(routingProvider.notifier).setProfile('cycling'); ref.read(routingProvider.notifier).calculateRoute(); }, ), ], ), ), const SizedBox(height: 16), // Loading if (routingState.isLoading) const Center( child: Padding( padding: EdgeInsets.all(32), child: CircularProgressIndicator(), ), ), // Error if (routingState.error != null) Padding( padding: const EdgeInsets.all(16), child: Card( color: Theme.of(context).colorScheme.errorContainer, child: Padding( padding: const EdgeInsets.all(16), child: Row( children: [ Icon(Icons.error_outline, color: Theme.of(context) .colorScheme .onErrorContainer), const SizedBox(width: 8), Expanded(child: Text(routingState.error!)), ], ), ), ), ), // Map with route if (routingState.routes.isNotEmpty) _buildRouteMap(context, routingState), // Route summaries if (routingState.routes.isNotEmpty) Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( children: [ for (var i = 0; i < routingState.routes.length; i++) RouteSummary( route: routingState.routes[i], profile: routingState.profile, isSelected: i == routingState.selectedRouteIndex, onTap: () => ref.read(routingProvider.notifier).selectRoute(i), ), ], ), ), // Step-by-step instructions if (routingState.selectedRoute != null && routingState.selectedRoute!.legs.isNotEmpty) ...[ Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Text( 'Turn-by-turn directions', style: Theme.of(context).textTheme.titleSmall, ), ), StepList( steps: routingState.selectedRoute!.legs .expand((leg) => leg.steps) .toList(), ), ], const SizedBox(height: 32), ], ), ), ); } Widget _buildRouteMap(BuildContext context, RoutingState routingState) { final selectedRoute = routingState.selectedRoute; if (selectedRoute == null) return const SizedBox.shrink(); final routePoints = selectedRoute.geometry .map((c) => LatLng(c[1], c[0])) .toList(); // Calculate bounds. double minLat = 90, maxLat = -90, minLon = 180, maxLon = -180; for (final p in routePoints) { if (p.latitude < minLat) minLat = p.latitude; if (p.latitude > maxLat) maxLat = p.latitude; if (p.longitude < minLon) minLon = p.longitude; if (p.longitude > maxLon) maxLon = p.longitude; } // Guard against degenerate bounds (single point or near-identical coords) // which cause CameraFit.bounds to produce infinite zoom → NaN → crash. final isDegenerate = (maxLat - minLat).abs() < 1e-6 && (maxLon - minLon).abs() < 1e-6; final mapOptions = isDegenerate ? MapOptions( initialCenter: LatLng(minLat, minLon), initialZoom: 15, interactionOptions: const InteractionOptions(flags: InteractiveFlag.none), ) : MapOptions( initialCameraFit: CameraFit.bounds( bounds: LatLngBounds( LatLng(minLat, minLon), LatLng(maxLat, maxLon), ), padding: const EdgeInsets.all(32), ), interactionOptions: const InteractionOptions(flags: InteractiveFlag.none), ); return Container( height: 250, margin: const EdgeInsets.all(16), clipBehavior: Clip.antiAlias, decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), ), child: FlutterMap( options: mapOptions, children: [ TileLayer( urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', tileProvider: NetworkTileProvider(), userAgentPackageName: 'com.privacymaps.app', ), // Draw all routes, non-selected as faint, selected as bold. for (var i = routingState.routes.length - 1; i >= 0; i--) PolylineLayer( polylines: [ Polyline( points: routingState.routes[i].geometry .map((c) => LatLng(c[1], c[0])) .toList(), strokeWidth: i == routingState.selectedRouteIndex ? 5.0 : 3.0, color: i == routingState.selectedRouteIndex ? Theme.of(context).colorScheme.primary : Theme.of(context) .colorScheme .outline .withValues(alpha: 0.5), ), ], ), // Origin and destination markers MarkerLayer( markers: [ if (routingState.originLat != null && routingState.originLon != null) Marker( point: LatLng( routingState.originLat!, routingState.originLon!, ), width: 20, height: 20, child: Container( decoration: BoxDecoration( color: Colors.green, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), ), ), ), if (routingState.destLat != null && routingState.destLon != null) Marker( point: LatLng( routingState.destLat!, routingState.destLon!, ), width: 20, height: 20, child: Container( decoration: BoxDecoration( color: Colors.red, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), ), ), ), ], ), ], ), ); } } class _ProfileChip extends StatelessWidget { final IconData icon; final String label; final bool isSelected; final VoidCallback onTap; const _ProfileChip({ required this.icon, required this.label, required this.isSelected, required this.onTap, }); @override Widget build(BuildContext context) { return ChoiceChip( avatar: Icon(icon, size: 18), label: Text(label), selected: isSelected, onSelected: (_) => onTap(), ); } } /// Tappable location field that opens an inline search dialog. class _LocationField extends ConsumerStatefulWidget { final String label; final IconData icon; final String? value; final ValueChanged onSelected; const _LocationField({ required this.label, required this.icon, required this.value, required this.onSelected, }); @override ConsumerState<_LocationField> createState() => _LocationFieldState(); } class _LocationFieldState extends ConsumerState<_LocationField> { Future _openSearch() async { final result = await showDialog( context: context, builder: (ctx) => _LocationSearchDialog(label: widget.label), ); if (result != null) widget.onSelected(result); } @override Widget build(BuildContext context) { return InkWell( onTap: _openSearch, borderRadius: BorderRadius.circular(8), child: InputDecorator( decoration: InputDecoration( labelText: widget.label, prefixIcon: Icon(widget.icon), suffixIcon: const Icon(Icons.edit, size: 16), border: const OutlineInputBorder(), ), child: Text(widget.value ?? ''), ), ); } } /// Dialog with a search field and live results. class _LocationSearchDialog extends ConsumerStatefulWidget { final String label; const _LocationSearchDialog({required this.label}); @override ConsumerState<_LocationSearchDialog> createState() => _LocationSearchDialogState(); } class _LocationSearchDialogState extends ConsumerState<_LocationSearchDialog> { final _controller = TextEditingController(); List _results = []; bool _loading = false; @override void dispose() { _controller.dispose(); super.dispose(); } Future _search(String query) async { if (query.trim().length < 2) { setState(() => _results = []); return; } setState(() => _loading = true); try { final repo = ref.read(searchRepositoryProvider); final results = await repo.search(query); if (mounted) setState(() => _results = results); } finally { if (mounted) setState(() => _loading = false); } } @override Widget build(BuildContext context) { return AlertDialog( title: Text(widget.label), contentPadding: const EdgeInsets.fromLTRB(16, 12, 16, 0), content: SizedBox( width: 400, child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: _controller, autofocus: true, decoration: const InputDecoration( hintText: 'Search location…', prefixIcon: Icon(Icons.search), ), onChanged: _search, ), if (_loading) const LinearProgressIndicator(), ConstrainedBox( constraints: const BoxConstraints(maxHeight: 300), child: ListView.builder( shrinkWrap: true, itemCount: _results.length, itemBuilder: (ctx, i) { final r = _results[i]; return ListTile( title: Text(r.name), subtitle: Text(r.displayAddress, maxLines: 1, overflow: TextOverflow.ellipsis), onTap: () => Navigator.of(ctx).pop(r), ); }, ), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), ], ); } }