896 lines
32 KiB
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));
|
|
}
|
|
}
|