import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:geolocator/geolocator.dart'; import 'package:http/http.dart' as http; import 'package:provider/provider.dart'; import '../models/stop.dart'; import '../providers/app_state.dart'; import '../providers/delivery_provider.dart'; import '../services/geocoding.dart'; import '../services/routing.dart'; import '../services/gpx_export.dart'; import 'builder_page.dart'; import 'statistics_page.dart'; import 'settings_page.dart'; class RoutePage extends StatefulWidget { final int routeId; final String name; const RoutePage({super.key, required this.routeId, required this.name}); @override State createState() => _RoutePageState(); } class _RoutePageState extends State { final _geocoding = GeocodingService(); final _routing = RoutingService(); final _mapController = MapController(); final _searchController = TextEditingController(); Position? _userPosition; List _routePoints = []; bool _showRoute = false; bool _isLoadingRoute = false; double _routeDistanceKm = 0.0; String _searchQuery = ''; late DeliveryProvider _deliveryProvider; static const _npColors = { 'BN': Colors.red, 'AD': Colors.blue, 'TEL': Colors.orange, 'VK': Colors.purple, }; @override void initState() { super.initState(); _deliveryProvider = DeliveryProvider(); _deliveryProvider.loadStops(widget.routeId); _deliveryProvider.addListener(_onDeliveryChanged); _initGps(); } @override void dispose() { _deliveryProvider.removeListener(_onDeliveryChanged); _deliveryProvider.dispose(); _searchController.dispose(); super.dispose(); } void _onDeliveryChanged() { if (mounted) setState(() {}); } Future _initGps() async { try { var perm = await Geolocator.checkPermission(); if (perm == LocationPermission.denied) { perm = await Geolocator.requestPermission(); } if (perm == LocationPermission.denied || perm == LocationPermission.deniedForever) return; Geolocator.getPositionStream( locationSettings: const LocationSettings(accuracy: LocationAccuracy.high), ).listen((p) { if (mounted) setState(() => _userPosition = p); }); } catch (_) {} } // ── Route Fetching ────────────────────────────────────── Future _fetchRoute() async { final stops = _deliveryProvider.stops; if (stops.length < 2) return; setState(() => _isLoadingRoute = true); // Fetch both route polyline and distance final result = await _fetchRouteWithDistance(stops); setState(() { if (result != null) { _routePoints = result.$1; _routeDistanceKm = result.$2; } else { _routePoints = stops.map((s) => s.location).toList(); _routeDistanceKm = 0.0; } _showRoute = true; _isLoadingRoute = false; }); } Future<(List, double)?> _fetchRouteWithDistance(List stops) async { if (stops.length < 2) return null; final coords = stops.map((s) => '${s.lng},${s.lat}').join(';'); final url = 'https://router.project-osrm.org/route/v1/driving/$coords?overview=full&geometries=polyline'; try { final resp = await http.get(Uri.parse(url)).timeout(const Duration(seconds: 10)); if (resp.statusCode == 200) { final data = jsonDecode(resp.body); if (data['code'] == 'Ok') { final routes = data['routes'] as List; if (routes.isNotEmpty) { final geometry = routes[0]['geometry'] as String; final distanceMeters = (routes[0]['distance'] as num).toDouble(); final points = RoutingService.decodePolyline(geometry); return (points, distanceMeters / 1000.0); } } } } catch (_) {} return null; } // ── Optimization ──────────────────────────────────────── Future _optimizeStops() async { final stops = _deliveryProvider.stops; if (stops.length < 2) return; if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Optimizing route...'), duration: Duration(seconds: 3)), ); } final optimized = await _routing.optimizeRoute(stops); final newStops = optimized ?? RoutingService.optimizeLocally(stops); await _deliveryProvider.reorderStops(newStops); } // ── Coordinate Refresh ────────────────────────────────── Future _refreshCoordinates() async { final stops = _deliveryProvider.stops; if (stops.isEmpty) return; if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Refreshing coordinates...'), duration: Duration(seconds: 2)), ); } for (final stop in stops) { try { final newPos = await _geocoding.geocode(stop.street, stop.houseNumber); if ((newPos.latitude - stop.lat).abs() > 0.0001 || (newPos.longitude - stop.lng).abs() > 0.0001) { await _deliveryProvider.updateCoords(stop.id!, newPos.latitude, newPos.longitude); } } catch (_) {} } if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Coordinates refreshed'), duration: Duration(seconds: 2)), ); } } // ── Export GPX ────────────────────────────────────────── Future _exportGpx() async { final stops = _deliveryProvider.stops; if (stops.isEmpty) return; try { final path = await GpxExportService.exportRoute( routeName: widget.name, stops: stops, trackPoints: _showRoute ? _routePoints : null, ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('GPX exported: $path'), duration: const Duration(seconds: 3)), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Export failed: $e'), backgroundColor: Colors.red), ); } } } // ── Reset Today ───────────────────────────────────────── Future _resetToday() async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Reset Today'), content: const Text( 'This will mark all stops as undelivered for today. ' 'Previous delivery records are preserved in history. Continue?'), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')), TextButton( onPressed: () => Navigator.pop(ctx, true), style: TextButton.styleFrom(foregroundColor: Colors.orange), child: const Text('Reset'), ), ], ), ); if (confirmed == true) { await _deliveryProvider.resetToday(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Today\'s deliveries have been reset')), ); } } } // ── Search ────────────────────────────────────────────── List _filteredStops(List stops) { if (_searchQuery.isEmpty) return stops; final q = _searchQuery.toLowerCase(); return stops.where((s) { return s.street.toLowerCase().contains(q) || s.houseNumber.toLowerCase().contains(q) || '${s.street} ${s.houseNumber}'.toLowerCase().contains(q); }).toList(); } // ── Stop Details ──────────────────────────────────────── void _showStopDetails(int index, List filteredStops) { final stop = filteredStops[index]; final notesCtrl = TextEditingController(text: stop.notes); final isDelivered = _deliveryProvider.todayDeliveredIds.contains(stop.id); showModalBottomSheet( context: context, isScrollControlled: true, builder: (ctx) => Padding( padding: EdgeInsets.only( left: 20, right: 20, top: 20, bottom: MediaQuery.of(ctx).viewInsets.bottom + 20, ), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${stop.street} ${stop.houseNumber}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), const SizedBox(height: 8), if (stop.newspapers.isNotEmpty) Wrap( spacing: 8, children: stop.newspapers .map((np) => Chip( label: Text(np), backgroundColor: _npColors[np] ?? Colors.grey, labelStyle: const TextStyle(color: Colors.white), )) .toList(), ), const SizedBox(height: 16), TextField( controller: notesCtrl, decoration: const InputDecoration( labelText: 'Notes', hintText: 'Dog, gate code...', border: OutlineInputBorder(), ), maxLines: 3, ), const SizedBox(height: 16), Row( children: [ Expanded( child: ElevatedButton( onPressed: () { _deliveryProvider.saveNotes(stop.id!, notesCtrl.text); _deliveryProvider.toggleDelivered(stop.id!); Navigator.pop(ctx); }, style: ElevatedButton.styleFrom( backgroundColor: isDelivered ? Colors.orange : Colors.green, ), child: Text(isDelivered ? 'Undo Delivery' : 'Mark Delivered'), ), ), const SizedBox(width: 8), IconButton( icon: const Icon(Icons.delete_outline, color: Colors.red), onPressed: () { Navigator.pop(ctx); _deleteStop(stop); }, ), ], ), const SizedBox(height: 8), SizedBox( width: double.infinity, child: OutlinedButton.icon( onPressed: () { _deliveryProvider.saveNotes(stop.id!, notesCtrl.text); Navigator.pop(ctx); }, icon: const Icon(Icons.save), label: const Text('Save Notes'), ), ), ], ), ), ), ).whenComplete(() { _deliveryProvider.saveNotes(stop.id!, notesCtrl.text); notesCtrl.dispose(); }); } Future _deleteStop(Stop stop) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Delete Stop'), content: Text('Delete ${stop.street} ${stop.houseNumber}?'), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')), TextButton( onPressed: () => Navigator.pop(ctx, true), style: TextButton.styleFrom(foregroundColor: Colors.red), child: const Text('Delete'), ), ], ), ); if (confirmed == true) { await _deliveryProvider.deleteStop(stop.id!); } } void _openBuilder() { Navigator.push( context, MaterialPageRoute( builder: (_) => BuilderPage(routeId: widget.routeId, onSave: () { _deliveryProvider.loadStops(widget.routeId); }), ), ); } // ── Build ─────────────────────────────────────────────── @override Widget build(BuildContext context) { final appState = context.watch(); final stops = _deliveryProvider.stopsWithDeliveryStatus; final filteredStops = _filteredStops(stops); final done = _deliveryProvider.deliveredCount; final total = _deliveryProvider.totalStops; final navMode = _deliveryProvider.navigateMode; return Scaffold( appBar: AppBar( title: Text('${widget.name} ($done/$total)'), actions: [ // Navigate mode toggle IconButton( icon: Icon(navMode ? Icons.stop_circle : Icons.navigation), onPressed: () { if (navMode) { _deliveryProvider.stopNavigation(); } else { _deliveryProvider.startNavigation(); } }, tooltip: navMode ? 'Stop Navigation' : 'Start Navigation', ), PopupMenuButton( icon: const Icon(Icons.more_vert), onSelected: _handleMenuAction, itemBuilder: (_) => [ const PopupMenuItem(value: 'stats', child: Text('📊 Statistics')), const PopupMenuItem(value: 'export', child: Text('📤 Export GPX')), const PopupMenuItem(value: 'reset', child: Text('🔄 Reset Today')), const PopupMenuItem(value: 'optimize', child: Text('⚡ Optimize Route')), const PopupMenuItem(value: 'refresh', child: Text('📍 Refresh Coords')), const PopupMenuItem(value: 'settings', child: Text('⚙️ Settings')), ], ), ], ), body: _buildBody(stops, filteredStops, navMode), floatingActionButton: FloatingActionButton( onPressed: _openBuilder, child: const Icon(Icons.add), ), ); } void _handleMenuAction(String action) { switch (action) { case 'stats': Navigator.push( context, MaterialPageRoute( builder: (_) => StatisticsPage( deliveryProvider: _deliveryProvider, routeName: widget.name, ), ), ); break; case 'export': _exportGpx(); break; case 'reset': _resetToday(); break; case 'optimize': _optimizeStops(); break; case 'refresh': _refreshCoordinates(); break; case 'settings': Navigator.push( context, MaterialPageRoute( builder: (_) => SettingsPage(appState: context.read()), ), ); break; } } Widget _buildBody(List allStops, List filteredStops, bool navMode) { if (_deliveryProvider.isLoading) { return const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [CircularProgressIndicator(), SizedBox(height: 16), Text('Loading stops...')], ), ); } if (_deliveryProvider.error != null) { return Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.error_outline, size: 48, color: Colors.red), const SizedBox(height: 16), Text(_deliveryProvider.error!, textAlign: TextAlign.center), const SizedBox(height: 16), ElevatedButton.icon( onPressed: () => _deliveryProvider.loadStops(widget.routeId), icon: const Icon(Icons.refresh), label: const Text('Retry'), ), ], ), ), ); } return Column( children: [ // Newspaper counts header if (allStops.isNotEmpty) _buildNewspaperHeader(), // Search bar Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: TextField( controller: _searchController, decoration: InputDecoration( hintText: 'Search by street or house number...', prefixIcon: const Icon(Icons.search), suffixIcon: _searchQuery.isNotEmpty ? IconButton( icon: const Icon(Icons.clear), onPressed: () { _searchController.clear(); setState(() => _searchQuery = ''); }, ) : null, border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), isDense: true, ), onChanged: (v) => setState(() => _searchQuery = v), ), ), // Navigation mode bar if (navMode) _buildNavigationBar(allStops), // Map Expanded( child: Stack( children: [ FlutterMap( mapController: _mapController, options: const MapOptions( initialCenter: LatLng(51.428, 4.330), initialZoom: 14, ), children: [ TileLayer( urlTemplate: context.read().tileUrl, subdomains: context.read().tileSubdomains.split(''), userAgentPackageName: 'com.delivery.route', ), MarkerLayer(markers: [ ...filteredStops.asMap().entries.map((e) { final stop = e.value; final isDelivered = _deliveryProvider.todayDeliveredIds.contains(stop.id); final isCurrentNav = navMode && e.key == _deliveryProvider.navigateIndex; return Marker( point: stop.location, width: isCurrentNav ? 32 : 24, height: isCurrentNav ? 32 : 24, child: GestureDetector( onTap: () => _showStopDetails(e.key, filteredStops), child: Container( decoration: BoxDecoration( color: isDelivered ? Colors.green.withValues(alpha: 0.6) : (stop.newspapers.isNotEmpty ? (_npColors[stop.newspapers.first] ?? Colors.grey) : Colors.grey), shape: BoxShape.circle, border: Border.all( color: isCurrentNav ? Colors.yellow : Colors.white, width: isCurrentNav ? 3 : 2, ), ), child: Center( child: Text( '${stop.sequence + 1}', style: const TextStyle( color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold, ), ), ), ), ), ); }), if (_userPosition != null) Marker( point: LatLng(_userPosition!.latitude, _userPosition!.longitude), width: 20, height: 20, child: Container( decoration: BoxDecoration( color: Colors.blue, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 3), ), ), ), ]), if (_showRoute && _routePoints.isNotEmpty) PolylineLayer( polylines: [ Polyline(points: _routePoints, color: Colors.blue, strokeWidth: 4.0) ], ), ], ), // Route distance badge if (_showRoute && _routeDistanceKm > 0) Positioned( top: 8, right: 8, child: Card( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.route, size: 16), const SizedBox(width: 4), Text( '${_routeDistanceKm.toStringAsFixed(1)} km', style: const TextStyle(fontWeight: FontWeight.bold), ), ], ), ), ), ), // Loading overlay if (_isLoadingRoute) Container( color: Colors.black.withValues(alpha: 0.3), child: const Center( child: Card( child: Padding( padding: EdgeInsets.all(20), child: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(), SizedBox(height: 16), Text('Calculating route...'), ], ), ), ), ), ), ], ), ), // Stops list SizedBox( height: 220, child: filteredStops.isEmpty ? Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( _searchQuery.isNotEmpty ? Icons.search_off : Icons.add_location_alt, size: 48, color: Colors.grey, ), const SizedBox(height: 8), Text( _searchQuery.isNotEmpty ? 'No matching stops' : 'No stops', style: const TextStyle(color: Colors.grey), ), if (_searchQuery.isEmpty) ...[ const SizedBox(height: 8), ElevatedButton.icon( onPressed: _openBuilder, icon: const Icon(Icons.add), label: const Text('Add Stops'), ), ], ], ), ) : ListView.builder( itemCount: filteredStops.length, itemBuilder: (ctx, i) { final stop = filteredStops[i]; final isDelivered = _deliveryProvider.todayDeliveredIds.contains(stop.id); final isCurrentNav = navMode && i == _deliveryProvider.navigateIndex; return ListTile( dense: true, selected: isCurrentNav, selectedTileColor: Colors.yellow.withValues(alpha: 0.15), leading: CircleAvatar( radius: 16, backgroundColor: isDelivered ? Colors.green : (stop.newspapers.isNotEmpty ? (_npColors[stop.newspapers.first] ?? Colors.grey) : Colors.grey), child: Text( '${stop.sequence + 1}', style: const TextStyle(color: Colors.white, fontSize: 12), ), ), title: Text( '${stop.street} ${stop.houseNumber}', style: const TextStyle(fontSize: 14), ), subtitle: _buildSubtitle(stop), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ if (stop.newspapers.length > 1) Padding( padding: const EdgeInsets.only(right: 4), child: Text( '${stop.newspapers.length}📰', style: const TextStyle(fontSize: 11), ), ), Checkbox( value: isDelivered, onChanged: (_) => _deliveryProvider.toggleDelivered(stop.id!), ), ], ), onTap: () => _showStopDetails(i, filteredStops), ); }, ), ), ], ); } // ── Newspaper Header ──────────────────────────────────── Widget _buildNewspaperHeader() { final counts = _deliveryProvider.newspaperCounts; final total = _deliveryProvider.totalNewspapers; return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), child: Row( children: [ const Icon(Icons.newspaper, size: 16), const SizedBox(width: 6), Text('$total papers', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), const SizedBox(width: 12), Expanded( child: Wrap( spacing: 8, children: counts.entries.map((e) { final color = _npColors[e.key] ?? Colors.grey; return Text( '${e.key}:${e.value}', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: color, ), ); }).toList(), ), ), // Route show/hide button GestureDetector( onTap: () { if (_showRoute) { setState(() => _showRoute = false); } else { _fetchRoute(); } }, child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( _showRoute ? Icons.route : Icons.route_outlined, size: 18, color: _showRoute ? Colors.blue : null, ), if (_showRoute && _routeDistanceKm > 0) Text( ' ${_routeDistanceKm.toStringAsFixed(1)}km', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), ), ], ), ), ], ), ); } // ── Navigation Bar ────────────────────────────────────── Widget _buildNavigationBar(List allStops) { final idx = _deliveryProvider.navigateIndex; final total = allStops.length; final currentStop = _deliveryProvider.currentNavigateStop; final delivered = _deliveryProvider.deliveredCount; final progress = _deliveryProvider.progressPercent; return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), color: Theme.of(context).colorScheme.primaryContainer, child: Column( mainAxisSize: MainAxisSize.min, children: [ // Progress bar LinearProgressIndicator( value: progress, minHeight: 6, borderRadius: BorderRadius.circular(3), ), const SizedBox(height: 6), Row( children: [ // Stop counter Text( 'Stop ${idx + 1} of $total', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15), ), const SizedBox(width: 12), // Delivered count Text( '$delivered delivered', style: TextStyle(color: Colors.grey[600], fontSize: 13), ), const Spacer(), // Navigation buttons IconButton( icon: const Icon(Icons.chevron_left), onPressed: idx > 0 ? _deliveryProvider.previousStop : null, iconSize: 28, ), // Mark delivered button if (currentStop != null) ElevatedButton.icon( onPressed: () => _deliveryProvider.toggleDelivered(currentStop.id!), icon: Icon( _deliveryProvider.todayDeliveredIds.contains(currentStop.id) ? Icons.undo : Icons.check, size: 18, ), label: Text( _deliveryProvider.todayDeliveredIds.contains(currentStop.id) ? 'Undo' : 'Deliver', ), style: ElevatedButton.styleFrom( backgroundColor: _deliveryProvider.todayDeliveredIds.contains(currentStop.id) ? Colors.orange : Colors.green, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), ), ), IconButton( icon: const Icon(Icons.chevron_right), onPressed: idx < total - 1 ? _deliveryProvider.nextStop : null, iconSize: 28, ), ], ), if (currentStop != null) ...[ const SizedBox(height: 4), Text( '${currentStop.street} ${currentStop.houseNumber}', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), ), if (currentStop.newspapers.isNotEmpty) Text( currentStop.newspapers.join(', '), style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), ], ], ), ); } Widget? _buildSubtitle(Stop stop) { final parts = []; if (stop.newspapers.isNotEmpty) parts.add(stop.newspapers.join(', ')); if (stop.notes.isNotEmpty) parts.add('📝 ${stop.notes}'); if (parts.isEmpty) return null; return Text(parts.join(' • '), style: const TextStyle(fontSize: 12)); } }