delivery_app/lib/pages/route_page.dart

896 lines
32 KiB
Dart

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<RoutePage> createState() => _RoutePageState();
}
class _RoutePageState extends State<RoutePage> {
final _geocoding = GeocodingService();
final _routing = RoutingService();
final _mapController = MapController();
final _searchController = TextEditingController();
Position? _userPosition;
List<LatLng> _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<void> _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<void> _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<LatLng>, double)?> _fetchRouteWithDistance(List<Stop> 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<void> _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<void> _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<void> _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<void> _resetToday() async {
final confirmed = await showDialog<bool>(
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<Stop> _filteredStops(List<Stop> 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<Stop> 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<void> _deleteStop(Stop stop) async {
final confirmed = await showDialog<bool>(
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<AppState>();
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<String>(
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<AppState>()),
),
);
break;
}
}
Widget _buildBody(List<Stop> allStops, List<Stop> 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<AppState>().tileUrl,
subdomains: context.read<AppState>().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<Stop> 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 = <String>[];
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));
}
}