fix: repair group ride routing and route planner
This commit is contained in:
parent
58193f6de4
commit
661cd07124
|
|
@ -100,10 +100,21 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!RoutePlannerNotifier.isValidCoordinate(_meetupPlace!.latitude, _meetupPlace!.longitude) ||
|
||||||
|
!RoutePlannerNotifier.isValidCoordinate(_destinationPlace!.latitude, _destinationPlace!.longitude)) {
|
||||||
|
setState(() {
|
||||||
|
_calculatedRouteResult = null;
|
||||||
|
_routeCalculationError = 'Invalid start or destination coordinates.';
|
||||||
|
_isCalculatingRoute = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final seq = ++_calculationSequence;
|
final seq = ++_calculationSequence;
|
||||||
setState(() {
|
setState(() {
|
||||||
_isCalculatingRoute = true;
|
_isCalculatingRoute = true;
|
||||||
_routeCalculationError = null;
|
_routeCalculationError = null;
|
||||||
|
_calculatedRouteResult = null; // Clear stale geometry immediately
|
||||||
});
|
});
|
||||||
|
|
||||||
final orderedStops = <RouteStop>[
|
final orderedStops = <RouteStop>[
|
||||||
|
|
@ -122,6 +133,14 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
|
|
||||||
for (int i = 0; i < _stops.length; i++) {
|
for (int i = 0; i < _stops.length; i++) {
|
||||||
final s = _stops[i];
|
final s = _stops[i];
|
||||||
|
if (!RoutePlannerNotifier.isValidCoordinate(s.latitude, s.longitude)) {
|
||||||
|
setState(() {
|
||||||
|
_isCalculatingRoute = false;
|
||||||
|
_calculatedRouteResult = null;
|
||||||
|
_routeCalculationError = 'Stop ${i + 1} has invalid coordinates.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
orderedStops.add(RouteStop(
|
orderedStops.add(RouteStop(
|
||||||
id: 'stop_$i',
|
id: 'stop_$i',
|
||||||
order: i + 1,
|
order: i + 1,
|
||||||
|
|
@ -267,7 +286,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
_selectedRouteId = route.id;
|
_selectedRouteId = route.id;
|
||||||
_selectedRouteTitle = route.title;
|
_selectedRouteTitle = route.title;
|
||||||
_selectedRouteDistanceMeters = route.totalDistanceMeters;
|
_selectedRouteDistanceMeters = route.totalDistanceMeters;
|
||||||
_selectedRouteStopCount = route.stops.length;
|
final ordered = route.orderedStops;
|
||||||
|
_selectedRouteStopCount = ordered.length;
|
||||||
|
|
||||||
if (_titleController.text.trim().isEmpty) {
|
if (_titleController.text.trim().isEmpty) {
|
||||||
_titleController.text = route.title;
|
_titleController.text = route.title;
|
||||||
|
|
@ -277,8 +297,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
_descriptionController.text = route.description;
|
_descriptionController.text = route.description;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (route.stops.isNotEmpty) {
|
if (ordered.isNotEmpty) {
|
||||||
final origin = route.stops.first;
|
final origin = ordered.first;
|
||||||
_meetupPlace = ResolvedPlace(
|
_meetupPlace = ResolvedPlace(
|
||||||
providerId: origin.id,
|
providerId: origin.id,
|
||||||
displayName: origin.displayName,
|
displayName: origin.displayName,
|
||||||
|
|
@ -291,8 +311,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
_meetupNotesController.text = origin.notes;
|
_meetupNotesController.text = origin.notes;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (route.stops.length > 1) {
|
if (ordered.length > 1) {
|
||||||
final dest = route.stops.last;
|
final dest = ordered.last;
|
||||||
_hasDestination = true;
|
_hasDestination = true;
|
||||||
_destinationPlace = ResolvedPlace(
|
_destinationPlace = ResolvedPlace(
|
||||||
providerId: dest.id,
|
providerId: dest.id,
|
||||||
|
|
@ -304,9 +324,9 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
_destNameController.text = dest.displayName;
|
_destNameController.text = dest.displayName;
|
||||||
|
|
||||||
_stops.clear();
|
_stops.clear();
|
||||||
if (route.stops.length > 2) {
|
if (ordered.length > 2) {
|
||||||
for (int i = 1; i < route.stops.length - 1; i++) {
|
for (int i = 1; i < ordered.length - 1; i++) {
|
||||||
final s = route.stops[i];
|
final s = ordered[i];
|
||||||
_stops.add(
|
_stops.add(
|
||||||
GroupRideLocation(
|
GroupRideLocation(
|
||||||
latitude: s.latitude,
|
latitude: s.latitude,
|
||||||
|
|
@ -327,6 +347,9 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
void _showSelectRouteSheet(MotorSignalThemeColors colors) {
|
void _showSelectRouteSheet(MotorSignalThemeColors colors) {
|
||||||
final user = ref.read(currentUserProvider);
|
final user = ref.read(currentUserProvider);
|
||||||
if (user == null) return;
|
if (user == null) return;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final onSurface = theme.colorScheme.onSurface;
|
||||||
|
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -345,18 +368,18 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Text(
|
||||||
'USE EXISTING ROUTE',
|
'USE EXISTING ROUTE',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: onSurface,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const Text(
|
Text(
|
||||||
'Pre-fills title, meetup location, intermediate waypoints, and destination.',
|
'Pre-fills title, meetup location, intermediate waypoints, and destination.',
|
||||||
style: TextStyle(color: Colors.white54, fontSize: 12),
|
style: TextStyle(color: onSurfaceVariant, fontSize: 12),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
routesAsync.when(
|
routesAsync.when(
|
||||||
|
|
@ -373,12 +396,12 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
),
|
),
|
||||||
data: (routes) {
|
data: (routes) {
|
||||||
if (routes.isEmpty) {
|
if (routes.isEmpty) {
|
||||||
return const Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.symmetric(vertical: 24),
|
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'No saved routes found.',
|
'No saved routes found.',
|
||||||
style: TextStyle(color: Colors.white54),
|
style: TextStyle(color: onSurfaceVariant),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -397,14 +420,14 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
color: colors.cyberCyan),
|
color: colors.cyberCyan),
|
||||||
title: Text(
|
title: Text(
|
||||||
route.title,
|
route.title,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: onSurface,
|
||||||
fontWeight: FontWeight.bold),
|
fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
'${route.stops.length} stops • ${route.formattedDistanceMiles}',
|
'${route.orderedStops.length} stops • ${route.formattedDistanceMiles}',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white54, fontSize: 12),
|
color: onSurfaceVariant, fontSize: 12),
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(sheetCtx);
|
Navigator.pop(sheetCtx);
|
||||||
|
|
@ -455,141 +478,152 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => StatefulBuilder(
|
builder: (ctx) => StatefulBuilder(
|
||||||
builder: (ctx, setDialogState) => AlertDialog(
|
builder: (ctx, setDialogState) {
|
||||||
backgroundColor: colors.surfaceContainerHigh,
|
final theme = Theme.of(ctx);
|
||||||
shape: RoundedRectangleBorder(
|
final onSurface = theme.colorScheme.onSurface;
|
||||||
borderRadius: BorderRadius.circular(16),
|
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
||||||
side: BorderSide(color: colors.outlineVariant),
|
|
||||||
),
|
return AlertDialog(
|
||||||
title: Text(
|
backgroundColor: colors.surfaceContainerHigh,
|
||||||
existing != null ? 'Edit Intermediate Stop' : 'Add Intermediate Stop',
|
shape: RoundedRectangleBorder(
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
side: BorderSide(color: colors.outlineVariant),
|
||||||
content: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
AddressAutocompleteField(
|
|
||||||
labelText: 'Stop Address / Location *',
|
|
||||||
hintText: 'Search address or landmark...',
|
|
||||||
initialPlace: resolved,
|
|
||||||
onPlaceResolved: (place) {
|
|
||||||
setDialogState(() {
|
|
||||||
resolved = place;
|
|
||||||
if (place != null && nameCtrl.text.trim().isEmpty) {
|
|
||||||
nameCtrl.text = place.displayName;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextField(
|
|
||||||
controller: nameCtrl,
|
|
||||||
style: const TextStyle(color: Colors.white),
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Custom Stop Label (Optional)',
|
|
||||||
labelStyle: const TextStyle(color: Colors.white70),
|
|
||||||
hintText: 'e.g. Shell Station / Scenic Overlook',
|
|
||||||
hintStyle: const TextStyle(color: Colors.white24),
|
|
||||||
filled: true,
|
|
||||||
fillColor: colors.surfaceContainer,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
DropdownButtonFormField<GroupRideLocationType>(
|
|
||||||
initialValue: stopType,
|
|
||||||
dropdownColor: colors.surfaceContainerHigh,
|
|
||||||
style: const TextStyle(color: Colors.white),
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Stop Type',
|
|
||||||
filled: true,
|
|
||||||
fillColor: colors.surfaceContainer,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
items: [
|
|
||||||
GroupRideLocationType.fuel,
|
|
||||||
GroupRideLocationType.rest,
|
|
||||||
GroupRideLocationType.food,
|
|
||||||
GroupRideLocationType.scenic,
|
|
||||||
GroupRideLocationType.waypoint,
|
|
||||||
].map((type) {
|
|
||||||
return DropdownMenuItem(
|
|
||||||
value: type,
|
|
||||||
child: Text(type.label),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
onChanged: (val) {
|
|
||||||
if (val != null) setDialogState(() => stopType = val);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextField(
|
|
||||||
controller: notesCtrl,
|
|
||||||
style: const TextStyle(color: Colors.white),
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Notes (optional)',
|
|
||||||
labelStyle: const TextStyle(color: Colors.white70),
|
|
||||||
hintText: 'e.g. Park near pumps, 15 min rest',
|
|
||||||
hintStyle: const TextStyle(color: Colors.white24),
|
|
||||||
filled: true,
|
|
||||||
fillColor: colors.surfaceContainer,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
title: Text(
|
||||||
actions: [
|
existing != null ? 'Edit Intermediate Stop' : 'Add Intermediate Stop',
|
||||||
TextButton(
|
style: TextStyle(color: onSurface, fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: const Text('CANCEL', style: TextStyle(color: Colors.white60)),
|
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
content: SingleChildScrollView(
|
||||||
style: ElevatedButton.styleFrom(
|
child: Column(
|
||||||
backgroundColor: colors.cyberCyan,
|
mainAxisSize: MainAxisSize.min,
|
||||||
foregroundColor: Colors.black,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
AddressAutocompleteField(
|
||||||
|
labelText: 'Stop Address / Location *',
|
||||||
|
hintText: 'Search address or landmark...',
|
||||||
|
initialPlace: resolved,
|
||||||
|
onPlaceResolved: (place) {
|
||||||
|
setDialogState(() {
|
||||||
|
resolved = place;
|
||||||
|
if (place != null && nameCtrl.text.trim().isEmpty) {
|
||||||
|
nameCtrl.text = place.displayName;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: nameCtrl,
|
||||||
|
style: TextStyle(color: onSurface),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Custom Stop Label (Optional)',
|
||||||
|
labelStyle: TextStyle(color: onSurfaceVariant),
|
||||||
|
hintText: 'e.g. Shell Station / Scenic Overlook',
|
||||||
|
hintStyle: TextStyle(color: onSurfaceVariant.withValues(alpha: 0.5)),
|
||||||
|
filled: true,
|
||||||
|
fillColor: colors.surfaceContainer,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
DropdownButtonFormField<GroupRideLocationType>(
|
||||||
|
initialValue: stopType,
|
||||||
|
dropdownColor: colors.surfaceContainerHigh,
|
||||||
|
style: TextStyle(color: onSurface),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Stop Type',
|
||||||
|
filled: true,
|
||||||
|
fillColor: colors.surfaceContainer,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
GroupRideLocationType.fuel,
|
||||||
|
GroupRideLocationType.rest,
|
||||||
|
GroupRideLocationType.food,
|
||||||
|
GroupRideLocationType.scenic,
|
||||||
|
GroupRideLocationType.waypoint,
|
||||||
|
].map((type) {
|
||||||
|
return DropdownMenuItem(
|
||||||
|
value: type,
|
||||||
|
child: Text(
|
||||||
|
type.label,
|
||||||
|
style: TextStyle(color: onSurface),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (val) {
|
||||||
|
if (val != null) setDialogState(() => stopType = val);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: notesCtrl,
|
||||||
|
style: TextStyle(color: onSurface),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Notes (optional)',
|
||||||
|
labelStyle: TextStyle(color: onSurfaceVariant),
|
||||||
|
hintText: 'e.g. Park near pumps, 15 min rest',
|
||||||
|
hintStyle: TextStyle(color: onSurfaceVariant.withValues(alpha: 0.5)),
|
||||||
|
filled: true,
|
||||||
|
fillColor: colors.surfaceContainer,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
onPressed: () {
|
|
||||||
if (resolved == null) {
|
|
||||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Please select an address from search suggestions.'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final loc = GroupRideLocation(
|
|
||||||
latitude: resolved!.latitude,
|
|
||||||
longitude: resolved!.longitude,
|
|
||||||
displayName: nameCtrl.text.trim().isNotEmpty
|
|
||||||
? nameCtrl.text.trim()
|
|
||||||
: resolved!.displayName,
|
|
||||||
address: resolved!.formattedAddress ?? '',
|
|
||||||
locationType: stopType,
|
|
||||||
notes: notesCtrl.text.trim(),
|
|
||||||
);
|
|
||||||
setState(() {
|
|
||||||
if (editIndex != null) {
|
|
||||||
_stops[editIndex] = loc;
|
|
||||||
} else {
|
|
||||||
_stops.add(loc);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Navigator.pop(ctx);
|
|
||||||
_triggerRouteCalculation();
|
|
||||||
},
|
|
||||||
child: Text(existing != null ? 'SAVE STOP' : 'ADD STOP'),
|
|
||||||
),
|
),
|
||||||
],
|
actions: [
|
||||||
),
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: Text('CANCEL', style: TextStyle(color: onSurfaceVariant)),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: colors.cyberCyan,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
if (resolved == null ||
|
||||||
|
!RoutePlannerNotifier.isValidCoordinate(
|
||||||
|
resolved!.latitude, resolved!.longitude)) {
|
||||||
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Please select a valid address with coordinates from search suggestions.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final loc = GroupRideLocation(
|
||||||
|
latitude: resolved!.latitude,
|
||||||
|
longitude: resolved!.longitude,
|
||||||
|
displayName: nameCtrl.text.trim().isNotEmpty
|
||||||
|
? nameCtrl.text.trim()
|
||||||
|
: resolved!.displayName,
|
||||||
|
address: resolved!.formattedAddress ?? '',
|
||||||
|
locationType: stopType,
|
||||||
|
notes: notesCtrl.text.trim(),
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
if (editIndex != null) {
|
||||||
|
_stops[editIndex] = loc;
|
||||||
|
} else {
|
||||||
|
_stops.add(loc);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
_triggerRouteCalculation();
|
||||||
|
},
|
||||||
|
child: Text(existing != null ? 'SAVE STOP' : 'ADD STOP'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -153,8 +153,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
existingStop != null ? 'Edit Stop' : 'Add Route Stop',
|
existingStop != null ? 'Edit Stop' : 'Add Route Stop',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -193,12 +193,12 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
controller: nameCtrl,
|
controller: nameCtrl,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Stop Name *',
|
labelText: 'Stop Name *',
|
||||||
labelStyle: const TextStyle(color: Colors.white60),
|
labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||||
hintText: 'e.g. Skyline Summit, Fuel Stop',
|
hintText: 'e.g. Skyline Summit, Fuel Stop',
|
||||||
hintStyle: const TextStyle(color: Colors.white24),
|
hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)),
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: Icon(Icons.search,
|
icon: Icon(Icons.search,
|
||||||
color: colors.cyberCyan, size: 20),
|
color: colors.cyberCyan, size: 20),
|
||||||
|
|
@ -226,10 +226,10 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
initialValue: selectedType,
|
initialValue: selectedType,
|
||||||
isExpanded: true,
|
isExpanded: true,
|
||||||
dropdownColor: colors.surfaceContainerHigh,
|
dropdownColor: colors.surfaceContainerHigh,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Stop Type',
|
labelText: 'Stop Type',
|
||||||
labelStyle: const TextStyle(color: Colors.white60),
|
labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||||
enabledBorder: UnderlineInputBorder(
|
enabledBorder: UnderlineInputBorder(
|
||||||
borderSide: BorderSide(color: colors.outlineVariant),
|
borderSide: BorderSide(color: colors.outlineVariant),
|
||||||
),
|
),
|
||||||
|
|
@ -248,6 +248,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
type.label,
|
type.label,
|
||||||
|
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -271,15 +272,15 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
decimal: true,
|
decimal: true,
|
||||||
signed: true,
|
signed: true,
|
||||||
),
|
),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
),
|
),
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Latitude *',
|
labelText: 'Latitude *',
|
||||||
labelStyle: TextStyle(color: Colors.white60),
|
labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||||
hintText: '37.421998',
|
hintText: '37.421998',
|
||||||
hintStyle: TextStyle(color: Colors.white24),
|
hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -291,15 +292,15 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
decimal: true,
|
decimal: true,
|
||||||
signed: true,
|
signed: true,
|
||||||
),
|
),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
),
|
),
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Longitude *',
|
labelText: 'Longitude *',
|
||||||
labelStyle: TextStyle(color: Colors.white60),
|
labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||||
hintText: '-122.084000',
|
hintText: '-122.084000',
|
||||||
hintStyle: TextStyle(color: Colors.white24),
|
hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -308,13 +309,13 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
controller: notesCtrl,
|
controller: notesCtrl,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Notes (Optional)',
|
labelText: 'Notes (Optional)',
|
||||||
labelStyle: TextStyle(color: Colors.white60),
|
labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||||
hintText: 'Regroup point, scenic view, cautions...',
|
hintText: 'Regroup point, scenic view, cautions...',
|
||||||
hintStyle: TextStyle(color: Colors.white24),
|
hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -323,9 +324,9 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(dialogCtx),
|
onPressed: () => Navigator.pop(dialogCtx),
|
||||||
child: const Text(
|
child: Text(
|
||||||
'CANCEL',
|
'CANCEL',
|
||||||
style: TextStyle(color: Colors.white54),
|
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
|
|
@ -346,18 +347,10 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (lat == null || lat < -90.0 || lat > 90.0) {
|
if (!RoutePlannerNotifier.isValidCoordinate(lat, lng)) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Invalid latitude (-90 to 90).'),
|
content: Text('Invalid coordinates: latitude [-90, 90], longitude [-180, 180], non-zero.'),
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (lng == null || lng < -180.0 || lng > 180.0) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Invalid longitude (-180 to 180).'),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|
@ -370,8 +363,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
notifier.updateStop(
|
notifier.updateStop(
|
||||||
existingStop.copyWith(
|
existingStop.copyWith(
|
||||||
displayName: name,
|
displayName: name,
|
||||||
latitude: lat,
|
latitude: lat!,
|
||||||
longitude: lng,
|
longitude: lng!,
|
||||||
stopType: selectedType,
|
stopType: selectedType,
|
||||||
notes: notesCtrl.text.trim(),
|
notes: notesCtrl.text.trim(),
|
||||||
),
|
),
|
||||||
|
|
@ -379,8 +372,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
} else {
|
} else {
|
||||||
notifier.addStop(
|
notifier.addStop(
|
||||||
displayName: name,
|
displayName: name,
|
||||||
latitude: lat,
|
latitude: lat!,
|
||||||
longitude: lng,
|
longitude: lng!,
|
||||||
stopType: selectedType,
|
stopType: selectedType,
|
||||||
notes: notesCtrl.text.trim(),
|
notes: notesCtrl.text.trim(),
|
||||||
);
|
);
|
||||||
|
|
@ -423,10 +416,10 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Text(
|
||||||
'SEARCH ADDRESS OR PLACE',
|
'SEARCH ADDRESS OR PLACE',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
|
|
@ -570,10 +563,12 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
const maxAllowed = RouteService.maxTechnicalStops;
|
const maxAllowed = RouteService.maxTechnicalStops;
|
||||||
final isAtLimit = state.stops.length >= maxAllowed;
|
final isAtLimit = state.stops.length >= maxAllowed;
|
||||||
|
|
||||||
// Center map on first stop or default
|
// Center map on road geometry, first stop, or default
|
||||||
final mapCenter = state.stops.isNotEmpty
|
final mapCenter = state.geometry.isNotEmpty
|
||||||
? AppLatLng(state.stops.first.latitude, state.stops.first.longitude)
|
? state.geometry.first
|
||||||
: const AppLatLng(37.7749, -122.4194);
|
: (state.stops.isNotEmpty
|
||||||
|
? AppLatLng(state.stops.first.latitude, state.stops.first.longitude)
|
||||||
|
: const AppLatLng(37.7749, -122.4194));
|
||||||
|
|
||||||
// Build markers
|
// Build markers
|
||||||
final markers = state.stops.map((stop) {
|
final markers = state.stops.map((stop) {
|
||||||
|
|
@ -618,19 +613,18 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
// Build polylines
|
// Build polylines from authoritative calculated road geometry
|
||||||
final polylines = state.stops.length >= 2
|
final polylines = <AppPolyline>[];
|
||||||
? [
|
if (state.geometry.isNotEmpty) {
|
||||||
AppPolyline(
|
polylines.add(
|
||||||
id: 'straight_line_preview',
|
AppPolyline(
|
||||||
points: state.previewPoints
|
id: 'route_planner_road_geometry',
|
||||||
.map((p) => AppLatLng(p.latitude, p.longitude))
|
points: state.geometry,
|
||||||
.toList(),
|
color: colors.cyberCyan,
|
||||||
color: colors.cyberCyan,
|
width: 3.5,
|
||||||
width: 3.5,
|
),
|
||||||
),
|
);
|
||||||
]
|
}
|
||||||
: <AppPolyline>[];
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colors.surfaceContainer,
|
backgroundColor: colors.surfaceContainer,
|
||||||
|
|
@ -638,12 +632,13 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
backgroundColor: colors.surfaceContainerHigh,
|
backgroundColor: colors.surfaceContainerHigh,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
icon: Icon(Icons.arrow_back, color: Theme.of(context).colorScheme.onSurface),
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => context.pop(),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
widget.initialRouteId != null ? 'EDIT ROUTE' : 'ROUTE PLANNER',
|
widget.initialRouteId != null ? 'EDIT ROUTE' : 'ROUTE PLANNER',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w900,
|
||||||
letterSpacing: 1.2,
|
letterSpacing: 1.2,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
|
|
@ -907,7 +902,10 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildErrorBanner(MotorSignalThemeColors colors) {
|
Widget _buildErrorBanner(MotorSignalThemeColors colors, [String? message]) {
|
||||||
|
final effectiveError = message ?? _errorMessage;
|
||||||
|
if (effectiveError == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
color: colors.raceRed.withValues(alpha: 0.15),
|
color: colors.raceRed.withValues(alpha: 0.15),
|
||||||
|
|
@ -925,7 +923,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
_errorMessage!,
|
effectiveError,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colors.raceRed,
|
color: colors.raceRed,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
|
|
@ -933,9 +931,9 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(
|
icon: Icon(
|
||||||
Icons.close,
|
Icons.close,
|
||||||
color: Colors.white54,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
size: 16,
|
size: 16,
|
||||||
),
|
),
|
||||||
onPressed: () => setState(() => _errorMessage = null),
|
onPressed: () => setState(() => _errorMessage = null),
|
||||||
|
|
@ -954,6 +952,20 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
bool isAtLimit,
|
bool isAtLimit,
|
||||||
bool isCompactHeight,
|
bool isCompactHeight,
|
||||||
) {
|
) {
|
||||||
|
String distanceText;
|
||||||
|
if (state.stops.length < 2) {
|
||||||
|
distanceText = 'Add 2+ stops for route';
|
||||||
|
} else if (state.isCalculatingRoute) {
|
||||||
|
distanceText = 'Calculating road route...';
|
||||||
|
} else if (state.routeCalculationError != null) {
|
||||||
|
distanceText = 'Route calculation failed';
|
||||||
|
} else if (state.geometry.isNotEmpty && state.estimatedDistanceMeters != null) {
|
||||||
|
distanceText =
|
||||||
|
'Road: ${state.formattedRoadDistanceMiles} (${state.formattedRoadDistanceKm})${state.formattedRoadDuration != null ? ' • ${state.formattedRoadDuration}' : ''}';
|
||||||
|
} else {
|
||||||
|
distanceText = 'Straight-line: ${state.formattedDistanceMiles} (${state.formattedDistanceKm})';
|
||||||
|
}
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(
|
||||||
horizontal: 8,
|
horizontal: 8,
|
||||||
|
|
@ -969,9 +981,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
state.stops.length >= 2
|
distanceText,
|
||||||
? 'Straight-line: ${state.formattedDistanceMiles} (${state.formattedDistanceKm})'
|
|
||||||
: 'Add 2+ stops for distance',
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colors.cyberCyan,
|
color: colors.cyberCyan,
|
||||||
fontSize: isCompactHeight ? 11 : 12,
|
fontSize: isCompactHeight ? 11 : 12,
|
||||||
|
|
@ -1007,33 +1017,38 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDisclaimerText() {
|
Widget _buildDisclaimerText() {
|
||||||
|
final theme = Theme.of(context);
|
||||||
return Text(
|
return Text(
|
||||||
'Straight-line preview. Navigation handoff launches in Google Maps / Waze.',
|
'Calculated road route. Navigation handoff launches in Google Maps / Waze.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white.withValues(alpha: 0.65),
|
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.75),
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
shadows: const [
|
shadows: const [
|
||||||
Shadow(color: Colors.black, blurRadius: 4),
|
Shadow(color: Colors.black26, blurRadius: 4),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildRouteInfoInputs(MotorSignalThemeColors colors, bool isCompactHeight) {
|
Widget _buildRouteInfoInputs(MotorSignalThemeColors colors, bool isCompactHeight) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final onSurface = theme.colorScheme.onSurface;
|
||||||
|
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
TextField(
|
TextField(
|
||||||
controller: _titleController,
|
controller: _titleController,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: onSurface,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
),
|
),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Route Title (e.g. Skyline Ridge Loop) *',
|
hintText: 'Route Title (e.g. Skyline Ridge Loop) *',
|
||||||
hintStyle: const TextStyle(
|
hintStyle: TextStyle(
|
||||||
color: Colors.white30,
|
color: onSurfaceVariant.withValues(alpha: 0.5),
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
isDense: true,
|
isDense: true,
|
||||||
|
|
@ -1060,10 +1075,10 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Icon(
|
||||||
Icons.visibility_outlined,
|
Icons.visibility_outlined,
|
||||||
size: 14,
|
size: 14,
|
||||||
color: Colors.white54,
|
color: onSurfaceVariant,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -1087,8 +1102,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
value: v,
|
value: v,
|
||||||
child: Text(
|
child: Text(
|
||||||
v.label,
|
v.label,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: onSurface,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -1164,14 +1179,17 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
onPressed: () => ref
|
onPressed: () => ref
|
||||||
.read(routePlannerNotifierProvider.notifier)
|
.read(routePlannerNotifierProvider.notifier)
|
||||||
.reverseStops(),
|
.reverseStops(),
|
||||||
icon: const Icon(
|
icon: Icon(
|
||||||
Icons.swap_vert,
|
Icons.swap_vert,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: Colors.white70,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
label: const Text(
|
label: Text(
|
||||||
'REVERSE',
|
'REVERSE',
|
||||||
style: TextStyle(color: Colors.white70, fontSize: 11),
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
|
|
@ -1192,6 +1210,10 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
bool isCompactHeight,
|
bool isCompactHeight,
|
||||||
bool isVeryCompactHeight,
|
bool isVeryCompactHeight,
|
||||||
) {
|
) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final onSurface = theme.colorScheme.onSurface;
|
||||||
|
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
||||||
|
|
||||||
if (state.stops.isEmpty) {
|
if (state.stops.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
|
|
@ -1205,22 +1227,22 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.alt_route,
|
Icons.alt_route,
|
||||||
size: isCompactHeight ? 32 : 48,
|
size: isCompactHeight ? 32 : 48,
|
||||||
color: Colors.white.withValues(alpha: 0.15),
|
color: onSurfaceVariant.withValues(alpha: 0.2),
|
||||||
),
|
),
|
||||||
SizedBox(height: isCompactHeight ? 6 : 12),
|
SizedBox(height: isCompactHeight ? 6 : 12),
|
||||||
],
|
],
|
||||||
const Text(
|
Text(
|
||||||
'No stops added yet.',
|
'No stops added yet.',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white54,
|
color: onSurfaceVariant,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
const Text(
|
Text(
|
||||||
'Tap "Add Stop" or "Current GPS" to begin planning.',
|
'Tap "Add Stop" or "Current GPS" to begin planning.',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white30,
|
color: onSurfaceVariant.withValues(alpha: 0.6),
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -1300,8 +1322,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
stop.displayName,
|
stop.displayName,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: onSurface,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
|
|
@ -1313,8 +1335,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
'${stop.latitude.toStringAsFixed(4)}, ${stop.longitude.toStringAsFixed(4)}',
|
'${stop.latitude.toStringAsFixed(4)}, ${stop.longitude.toStringAsFixed(4)}',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white38,
|
color: onSurfaceVariant,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -1322,8 +1344,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
stop.notes,
|
stop.notes,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white70,
|
color: onSurfaceVariant,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontStyle: FontStyle.italic,
|
fontStyle: FontStyle.italic,
|
||||||
),
|
),
|
||||||
|
|
@ -1356,9 +1378,9 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(
|
icon: Icon(
|
||||||
Icons.edit,
|
Icons.edit,
|
||||||
color: Colors.white60,
|
color: onSurfaceVariant,
|
||||||
size: 18,
|
size: 18,
|
||||||
),
|
),
|
||||||
tooltip: 'Edit stop',
|
tooltip: 'Edit stop',
|
||||||
|
|
@ -1390,9 +1412,9 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
const Icon(
|
Icon(
|
||||||
Icons.drag_handle,
|
Icons.drag_handle,
|
||||||
color: Colors.white30,
|
color: onSurfaceVariant.withValues(alpha: 0.4),
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,9 @@ class _AddressAutocompleteFieldState
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colors = context.motorColors;
|
final colors = context.motorColors;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final onSurface = theme.colorScheme.onSurface;
|
||||||
|
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
||||||
final isResolved = _resolvedPlace != null;
|
final isResolved = _resolvedPlace != null;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
|
|
@ -299,7 +302,7 @@ class _AddressAutocompleteFieldState
|
||||||
focusNode: _focusNode,
|
focusNode: _focusNode,
|
||||||
enabled: widget.enabled && !_isResolving,
|
enabled: widget.enabled && !_isResolving,
|
||||||
autofocus: widget.autofocus,
|
autofocus: widget.autofocus,
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 14),
|
style: TextStyle(color: onSurface, fontSize: 14),
|
||||||
onChanged: _onTextChanged,
|
onChanged: _onTextChanged,
|
||||||
validator: (val) {
|
validator: (val) {
|
||||||
if (widget.isRequired && (val == null || val.trim().isEmpty)) {
|
if (widget.isRequired && (val == null || val.trim().isEmpty)) {
|
||||||
|
|
@ -317,10 +320,10 @@ class _AddressAutocompleteFieldState
|
||||||
labelText: widget.labelText,
|
labelText: widget.labelText,
|
||||||
hintText: widget.hintText ?? 'Type address or place name...',
|
hintText: widget.hintText ?? 'Type address or place name...',
|
||||||
labelStyle: TextStyle(
|
labelStyle: TextStyle(
|
||||||
color: isResolved ? colors.launchGreen : Colors.white70,
|
color: isResolved ? colors.launchGreen : onSurfaceVariant,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
),
|
),
|
||||||
hintStyle: const TextStyle(color: Colors.white24, fontSize: 13),
|
hintStyle: TextStyle(color: onSurfaceVariant.withValues(alpha: 0.5), fontSize: 13),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: colors.surfaceContainerHigh,
|
fillColor: colors.surfaceContainerHigh,
|
||||||
prefixIcon:
|
prefixIcon:
|
||||||
|
|
@ -346,10 +349,10 @@ class _AddressAutocompleteFieldState
|
||||||
)
|
)
|
||||||
: _controller.text.isNotEmpty
|
: _controller.text.isNotEmpty
|
||||||
? IconButton(
|
? IconButton(
|
||||||
icon: const Icon(
|
icon: Icon(
|
||||||
Icons.clear,
|
Icons.clear,
|
||||||
size: 18,
|
size: 18,
|
||||||
color: Colors.white54,
|
color: onSurfaceVariant,
|
||||||
),
|
),
|
||||||
onPressed: _clear,
|
onPressed: _clear,
|
||||||
tooltip: 'Clear location',
|
tooltip: 'Clear location',
|
||||||
|
|
@ -391,6 +394,10 @@ class _AddressAutocompleteFieldState
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDropdownPanel(MotorSignalThemeColors colors) {
|
Widget _buildDropdownPanel(MotorSignalThemeColors colors) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final onSurface = theme.colorScheme.onSurface;
|
||||||
|
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
color: colors.surfaceContainerHighest,
|
color: colors.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
|
@ -423,9 +430,9 @@ class _AddressAutocompleteFieldState
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
const Text(
|
Text(
|
||||||
'Searching addresses...',
|
'Searching addresses...',
|
||||||
style: TextStyle(color: Colors.white70, fontSize: 12),
|
style: TextStyle(color: onSurfaceVariant, fontSize: 12),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -444,8 +451,8 @@ class _AddressAutocompleteFieldState
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
_errorMessage!,
|
_errorMessage!,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white70,
|
color: onSurfaceVariant,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -465,27 +472,27 @@ class _AddressAutocompleteFieldState
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else if (_hasSearched && _suggestions.isEmpty)
|
else if (_hasSearched && _suggestions.isEmpty)
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.location_off,
|
Icons.location_off,
|
||||||
color: Colors.white38,
|
color: onSurfaceVariant.withValues(alpha: 0.5),
|
||||||
size: 16,
|
size: 16,
|
||||||
),
|
),
|
||||||
SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'No matching addresses found',
|
'No matching addresses found',
|
||||||
style: TextStyle(color: Colors.white54, fontSize: 12),
|
style: TextStyle(color: onSurfaceVariant, fontSize: 12),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
for (int i = 0; i < _suggestions.length; i++) ...[
|
for (int i = 0; i < _suggestions.length; i++) ...[
|
||||||
if (i > 0) const Divider(height: 1, color: Colors.white10),
|
if (i > 0) Divider(height: 1, color: colors.outlineVariant),
|
||||||
ListTile(
|
ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
|
@ -499,8 +506,8 @@ class _AddressAutocompleteFieldState
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
_suggestions[i].primaryText,
|
_suggestions[i].primaryText,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: onSurface,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
|
|
@ -510,8 +517,8 @@ class _AddressAutocompleteFieldState
|
||||||
subtitle: _suggestions[i].secondaryText.isNotEmpty
|
subtitle: _suggestions[i].secondaryText.isNotEmpty
|
||||||
? Text(
|
? Text(
|
||||||
_suggestions[i].secondaryText,
|
_suggestions[i].secondaryText,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white54,
|
color: onSurfaceVariant,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
|
|
@ -528,10 +535,10 @@ class _AddressAutocompleteFieldState
|
||||||
vertical: 4,
|
vertical: 4,
|
||||||
),
|
),
|
||||||
color: colors.surfaceContainer,
|
color: colors.surfaceContainer,
|
||||||
child: const Text(
|
child: Text(
|
||||||
'Search by Stadia Maps / Pelias • © OpenStreetMap contributors',
|
'Search by Stadia Maps / Pelias • © OpenStreetMap contributors',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white38,
|
color: onSurfaceVariant.withValues(alpha: 0.6),
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
letterSpacing: 0.2,
|
letterSpacing: 0.2,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,11 @@ class RoutePlannerState {
|
||||||
final RouteVisibility visibility;
|
final RouteVisibility visibility;
|
||||||
final List<RouteStop> stops;
|
final List<RouteStop> stops;
|
||||||
final double straightLineDistanceMeters;
|
final double straightLineDistanceMeters;
|
||||||
|
final List<AppLatLng> geometry;
|
||||||
|
final double? estimatedDistanceMeters;
|
||||||
|
final int? estimatedDurationSeconds;
|
||||||
|
final bool isCalculatingRoute;
|
||||||
|
final String? routeCalculationError;
|
||||||
final bool isSaving;
|
final bool isSaving;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
final bool isDirty;
|
final bool isDirty;
|
||||||
|
|
@ -76,6 +81,11 @@ class RoutePlannerState {
|
||||||
this.visibility = RouteVisibility.private,
|
this.visibility = RouteVisibility.private,
|
||||||
this.stops = const [],
|
this.stops = const [],
|
||||||
this.straightLineDistanceMeters = 0.0,
|
this.straightLineDistanceMeters = 0.0,
|
||||||
|
this.geometry = const [],
|
||||||
|
this.estimatedDistanceMeters,
|
||||||
|
this.estimatedDurationSeconds,
|
||||||
|
this.isCalculatingRoute = false,
|
||||||
|
this.routeCalculationError,
|
||||||
this.isSaving = false,
|
this.isSaving = false,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
this.isDirty = false,
|
this.isDirty = false,
|
||||||
|
|
@ -84,13 +94,38 @@ class RoutePlannerState {
|
||||||
|
|
||||||
double get distanceMiles => straightLineDistanceMeters * 0.000621371;
|
double get distanceMiles => straightLineDistanceMeters * 0.000621371;
|
||||||
|
|
||||||
String get formattedDistance =>
|
double? get roadDistanceMiles =>
|
||||||
'${distanceMiles.toStringAsFixed(1)} mi (Straight-line)';
|
estimatedDistanceMeters != null ? estimatedDistanceMeters! * 0.000621371 : null;
|
||||||
|
|
||||||
String get formattedDistanceMiles => '${distanceMiles.toStringAsFixed(1)} mi';
|
String? get formattedRoadDistanceMiles =>
|
||||||
|
roadDistanceMiles != null ? '${roadDistanceMiles!.toStringAsFixed(1)} mi' : null;
|
||||||
|
|
||||||
|
String? get formattedRoadDistanceKm =>
|
||||||
|
estimatedDistanceMeters != null ? '${(estimatedDistanceMeters! / 1000).toStringAsFixed(1)} km' : null;
|
||||||
|
|
||||||
|
String? get formattedRoadDuration {
|
||||||
|
if (estimatedDurationSeconds == null) return null;
|
||||||
|
final mins = (estimatedDurationSeconds! / 60).round();
|
||||||
|
if (mins < 60) return '$mins min';
|
||||||
|
final hrs = mins ~/ 60;
|
||||||
|
final remMins = mins % 60;
|
||||||
|
return '${hrs}h ${remMins}m';
|
||||||
|
}
|
||||||
|
|
||||||
|
String get formattedDistance {
|
||||||
|
if (estimatedDistanceMeters != null) {
|
||||||
|
return '${roadDistanceMiles!.toStringAsFixed(1)} mi (Road Est.)';
|
||||||
|
}
|
||||||
|
return '${distanceMiles.toStringAsFixed(1)} mi (Straight-line)';
|
||||||
|
}
|
||||||
|
|
||||||
|
String get formattedDistanceMiles =>
|
||||||
|
roadDistanceMiles != null ? '${roadDistanceMiles!.toStringAsFixed(1)} mi' : '${distanceMiles.toStringAsFixed(1)} mi';
|
||||||
|
|
||||||
String get formattedDistanceKm =>
|
String get formattedDistanceKm =>
|
||||||
'${(straightLineDistanceMeters / 1000).toStringAsFixed(1)} km';
|
estimatedDistanceMeters != null
|
||||||
|
? '${(estimatedDistanceMeters! / 1000).toStringAsFixed(1)} km'
|
||||||
|
: '${(straightLineDistanceMeters / 1000).toStringAsFixed(1)} km';
|
||||||
|
|
||||||
List<AppLatLng> get previewPoints => stops.map((s) => s.latLng).toList();
|
List<AppLatLng> get previewPoints => stops.map((s) => s.latLng).toList();
|
||||||
|
|
||||||
|
|
@ -101,8 +136,17 @@ class RoutePlannerState {
|
||||||
RouteVisibility? visibility,
|
RouteVisibility? visibility,
|
||||||
List<RouteStop>? stops,
|
List<RouteStop>? stops,
|
||||||
double? straightLineDistanceMeters,
|
double? straightLineDistanceMeters,
|
||||||
|
List<AppLatLng>? geometry,
|
||||||
|
double? estimatedDistanceMeters,
|
||||||
|
bool clearEstimatedDistance = false,
|
||||||
|
int? estimatedDurationSeconds,
|
||||||
|
bool clearEstimatedDuration = false,
|
||||||
|
bool? isCalculatingRoute,
|
||||||
|
String? routeCalculationError,
|
||||||
|
bool clearRouteCalculationError = false,
|
||||||
bool? isSaving,
|
bool? isSaving,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
|
bool clearErrorMessage = false,
|
||||||
bool? isDirty,
|
bool? isDirty,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -114,8 +158,19 @@ class RoutePlannerState {
|
||||||
stops: stops ?? this.stops,
|
stops: stops ?? this.stops,
|
||||||
straightLineDistanceMeters:
|
straightLineDistanceMeters:
|
||||||
straightLineDistanceMeters ?? this.straightLineDistanceMeters,
|
straightLineDistanceMeters ?? this.straightLineDistanceMeters,
|
||||||
|
geometry: geometry ?? this.geometry,
|
||||||
|
estimatedDistanceMeters: clearEstimatedDistance
|
||||||
|
? null
|
||||||
|
: (estimatedDistanceMeters ?? this.estimatedDistanceMeters),
|
||||||
|
estimatedDurationSeconds: clearEstimatedDuration
|
||||||
|
? null
|
||||||
|
: (estimatedDurationSeconds ?? this.estimatedDurationSeconds),
|
||||||
|
isCalculatingRoute: isCalculatingRoute ?? this.isCalculatingRoute,
|
||||||
|
routeCalculationError: clearRouteCalculationError
|
||||||
|
? null
|
||||||
|
: (routeCalculationError ?? this.routeCalculationError),
|
||||||
isSaving: isSaving ?? this.isSaving,
|
isSaving: isSaving ?? this.isSaving,
|
||||||
errorMessage: errorMessage,
|
errorMessage: clearErrorMessage ? null : (errorMessage ?? this.errorMessage),
|
||||||
isDirty: isDirty ?? this.isDirty,
|
isDirty: isDirty ?? this.isDirty,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
);
|
);
|
||||||
|
|
@ -125,6 +180,7 @@ class RoutePlannerState {
|
||||||
/// Controller managing in-memory route editing state.
|
/// Controller managing in-memory route editing state.
|
||||||
class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
|
class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
|
||||||
final Ref ref;
|
final Ref ref;
|
||||||
|
int _calculationSequence = 0;
|
||||||
|
|
||||||
RoutePlannerNotifier(this.ref, [MotorSignalRoute? initialRoute])
|
RoutePlannerNotifier(this.ref, [MotorSignalRoute? initialRoute])
|
||||||
: super(
|
: super(
|
||||||
|
|
@ -140,9 +196,23 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
|
||||||
createdAt: initialRoute.createdAt,
|
createdAt: initialRoute.createdAt,
|
||||||
)
|
)
|
||||||
: const RoutePlannerState(),
|
: const RoutePlannerState(),
|
||||||
);
|
) {
|
||||||
|
if (initialRoute != null && initialRoute.orderedStops.length >= 2) {
|
||||||
|
_calculateRoadRoute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool isValidCoordinate(double? lat, double? lng) {
|
||||||
|
if (lat == null || lng == null) return false;
|
||||||
|
if (lat.isNaN || lng.isNaN || lat.isInfinite || lng.isInfinite) return false;
|
||||||
|
if (lat < -90.0 || lat > 90.0) return false;
|
||||||
|
if (lng < -180.0 || lng > 180.0) return false;
|
||||||
|
if (lat == 0.0 && lng == 0.0) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void loadRoute(MotorSignalRoute route) {
|
void loadRoute(MotorSignalRoute route) {
|
||||||
|
_calculationSequence++;
|
||||||
state = RoutePlannerState(
|
state = RoutePlannerState(
|
||||||
routeId: route.id,
|
routeId: route.id,
|
||||||
title: route.title,
|
title: route.title,
|
||||||
|
|
@ -153,9 +223,13 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
|
||||||
createdAt: route.createdAt,
|
createdAt: route.createdAt,
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
);
|
);
|
||||||
|
if (route.orderedStops.length >= 2) {
|
||||||
|
_calculateRoadRoute();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void reset() {
|
void reset() {
|
||||||
|
_calculationSequence++;
|
||||||
state = const RoutePlannerState();
|
state = const RoutePlannerState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,56 +253,124 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
|
||||||
RouteStopType? stopType,
|
RouteStopType? stopType,
|
||||||
String? notes,
|
String? notes,
|
||||||
}) {
|
}) {
|
||||||
|
final effectiveLat = stop?.latitude ?? latitude;
|
||||||
|
final effectiveLng = stop?.longitude ?? longitude;
|
||||||
|
|
||||||
|
if (!isValidCoordinate(effectiveLat, effectiveLng)) {
|
||||||
|
state = state.copyWith(
|
||||||
|
errorMessage: 'Invalid coordinates: latitude [-90, 90], longitude [-180, 180], non-zero.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final effectiveType = stop?.stopType ?? stopType ?? (state.stops.isEmpty ? RouteStopType.origin : RouteStopType.waypoint);
|
||||||
final updatedStops = List<RouteStop>.from(state.stops);
|
final updatedStops = List<RouteStop>.from(state.stops);
|
||||||
final effectiveOrder = updatedStops.length;
|
|
||||||
|
|
||||||
final newStop = stop != null
|
final newStop = stop != null
|
||||||
? stop.copyWith(order: effectiveOrder, orderIndex: effectiveOrder)
|
? stop.copyWith(
|
||||||
|
latitude: effectiveLat!,
|
||||||
|
longitude: effectiveLng!,
|
||||||
|
type: effectiveType,
|
||||||
|
stopType: effectiveType,
|
||||||
|
)
|
||||||
: RouteStop(
|
: RouteStop(
|
||||||
id: 'stop_${DateTime.now().millisecondsSinceEpoch}_$effectiveOrder',
|
id: 'stop_${DateTime.now().millisecondsSinceEpoch}_${updatedStops.length}',
|
||||||
order: effectiveOrder,
|
order: updatedStops.length,
|
||||||
orderIndex: effectiveOrder,
|
orderIndex: updatedStops.length,
|
||||||
type: stopType ?? RouteStopType.waypoint,
|
type: effectiveType,
|
||||||
stopType: stopType ?? RouteStopType.waypoint,
|
stopType: effectiveType,
|
||||||
displayName: displayName ?? 'Stop $effectiveOrder',
|
displayName: displayName ?? 'Stop ${updatedStops.length + 1}',
|
||||||
latitude: latitude ?? 0.0,
|
latitude: effectiveLat!,
|
||||||
longitude: longitude ?? 0.0,
|
longitude: effectiveLng!,
|
||||||
notes: notes ?? '',
|
notes: notes ?? '',
|
||||||
);
|
);
|
||||||
|
|
||||||
updatedStops.add(newStop);
|
// Canonical ordering:
|
||||||
|
// 1. If origin/start, goes at index 0.
|
||||||
|
// 2. If destination, goes at end.
|
||||||
|
// 3. If waypoint/intermediate, and the list already ends with a destination,
|
||||||
|
// insert BEFORE the destination stop!
|
||||||
|
if (effectiveType == RouteStopType.origin || effectiveType == RouteStopType.start) {
|
||||||
|
updatedStops.insert(0, newStop);
|
||||||
|
} else if (effectiveType == RouteStopType.destination) {
|
||||||
|
updatedStops.add(newStop);
|
||||||
|
} else {
|
||||||
|
// Waypoint / intermediate
|
||||||
|
if (updatedStops.isNotEmpty &&
|
||||||
|
(updatedStops.last.stopType == RouteStopType.destination ||
|
||||||
|
updatedStops.last.type == RouteStopType.destination)) {
|
||||||
|
updatedStops.insert(updatedStops.length - 1, newStop);
|
||||||
|
} else {
|
||||||
|
updatedStops.add(newStop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final distance =
|
// Re-index all stops
|
||||||
MotorSignalRoute.calculateTotalDistanceMeters(updatedStops);
|
final reindexed = <RouteStop>[];
|
||||||
|
for (int i = 0; i < updatedStops.length; i++) {
|
||||||
|
reindexed.add(updatedStops[i].copyWith(order: i, orderIndex: i));
|
||||||
|
}
|
||||||
|
|
||||||
|
final distance = MotorSignalRoute.calculateTotalDistanceMeters(reindexed);
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
stops: updatedStops,
|
stops: reindexed,
|
||||||
straightLineDistanceMeters: distance,
|
straightLineDistanceMeters: distance,
|
||||||
|
geometry: const [], // Invalidate stale geometry immediately!
|
||||||
|
clearEstimatedDistance: true,
|
||||||
|
clearEstimatedDuration: true,
|
||||||
|
isCalculatingRoute: reindexed.length >= 2,
|
||||||
|
clearRouteCalculationError: true,
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
errorMessage: null,
|
clearErrorMessage: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (reindexed.length >= 2) {
|
||||||
|
_calculateRoadRoute();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateStop(dynamic target, [RouteStop? maybeStop]) {
|
void updateStop(dynamic target, [RouteStop? maybeStop]) {
|
||||||
final updatedStops = List<RouteStop>.from(state.stops);
|
final updatedStops = List<RouteStop>.from(state.stops);
|
||||||
|
int targetIdx = -1;
|
||||||
|
RouteStop updated;
|
||||||
|
|
||||||
if (target is RouteStop) {
|
if (target is RouteStop) {
|
||||||
final idx = updatedStops.indexWhere((s) => s.id == target.id);
|
targetIdx = updatedStops.indexWhere((s) => s.id == target.id);
|
||||||
if (idx == -1) return;
|
if (targetIdx == -1) return;
|
||||||
updatedStops[idx] = target.copyWith(order: idx, orderIndex: idx);
|
updated = target;
|
||||||
} else if (target is int && maybeStop != null) {
|
} else if (target is int && maybeStop != null) {
|
||||||
if (target < 0 || target >= updatedStops.length) return;
|
if (target < 0 || target >= updatedStops.length) return;
|
||||||
updatedStops[target] = maybeStop.copyWith(order: target, orderIndex: target);
|
targetIdx = target;
|
||||||
|
updated = maybeStop;
|
||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final distance =
|
if (!isValidCoordinate(updated.latitude, updated.longitude)) {
|
||||||
MotorSignalRoute.calculateTotalDistanceMeters(updatedStops);
|
state = state.copyWith(
|
||||||
|
errorMessage: 'Invalid coordinates for updated stop.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedStops[targetIdx] = updated.copyWith(order: targetIdx, orderIndex: targetIdx);
|
||||||
|
|
||||||
|
final distance = MotorSignalRoute.calculateTotalDistanceMeters(updatedStops);
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
stops: updatedStops,
|
stops: updatedStops,
|
||||||
straightLineDistanceMeters: distance,
|
straightLineDistanceMeters: distance,
|
||||||
|
geometry: const [], // Invalidate stale geometry
|
||||||
|
clearEstimatedDistance: true,
|
||||||
|
clearEstimatedDuration: true,
|
||||||
|
isCalculatingRoute: updatedStops.length >= 2,
|
||||||
|
clearRouteCalculationError: true,
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
errorMessage: null,
|
clearErrorMessage: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (updatedStops.length >= 2) {
|
||||||
|
_calculateRoadRoute();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void removeStop(dynamic target) {
|
void removeStop(dynamic target) {
|
||||||
|
|
@ -242,7 +384,6 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-index remaining stops
|
|
||||||
final reindexed = <RouteStop>[];
|
final reindexed = <RouteStop>[];
|
||||||
for (int i = 0; i < updatedStops.length; i++) {
|
for (int i = 0; i < updatedStops.length; i++) {
|
||||||
reindexed.add(updatedStops[i].copyWith(order: i, orderIndex: i));
|
reindexed.add(updatedStops[i].copyWith(order: i, orderIndex: i));
|
||||||
|
|
@ -252,9 +393,18 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
stops: reindexed,
|
stops: reindexed,
|
||||||
straightLineDistanceMeters: distance,
|
straightLineDistanceMeters: distance,
|
||||||
|
geometry: const [], // Invalidate stale geometry
|
||||||
|
clearEstimatedDistance: true,
|
||||||
|
clearEstimatedDuration: true,
|
||||||
|
isCalculatingRoute: reindexed.length >= 2,
|
||||||
|
clearRouteCalculationError: true,
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
errorMessage: null,
|
clearErrorMessage: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (reindexed.length >= 2) {
|
||||||
|
_calculateRoadRoute();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void reorderStops(int oldIndex, int newIndex) {
|
void reorderStops(int oldIndex, int newIndex) {
|
||||||
|
|
@ -275,9 +425,18 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
stops: reindexed,
|
stops: reindexed,
|
||||||
straightLineDistanceMeters: distance,
|
straightLineDistanceMeters: distance,
|
||||||
|
geometry: const [], // Invalidate stale geometry
|
||||||
|
clearEstimatedDistance: true,
|
||||||
|
clearEstimatedDuration: true,
|
||||||
|
isCalculatingRoute: reindexed.length >= 2,
|
||||||
|
clearRouteCalculationError: true,
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
errorMessage: null,
|
clearErrorMessage: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (reindexed.length >= 2) {
|
||||||
|
_calculateRoadRoute();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void reverseStops() {
|
void reverseStops() {
|
||||||
|
|
@ -292,9 +451,65 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
stops: reindexed,
|
stops: reindexed,
|
||||||
straightLineDistanceMeters: distance,
|
straightLineDistanceMeters: distance,
|
||||||
|
geometry: const [], // Invalidate stale geometry
|
||||||
|
clearEstimatedDistance: true,
|
||||||
|
clearEstimatedDuration: true,
|
||||||
|
isCalculatingRoute: reindexed.length >= 2,
|
||||||
|
clearRouteCalculationError: true,
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
errorMessage: null,
|
clearErrorMessage: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (reindexed.length >= 2) {
|
||||||
|
_calculateRoadRoute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> recalculateRoute() => _calculateRoadRoute();
|
||||||
|
|
||||||
|
Future<void> _calculateRoadRoute() async {
|
||||||
|
if (state.stops.length < 2) {
|
||||||
|
state = state.copyWith(
|
||||||
|
geometry: const [],
|
||||||
|
clearEstimatedDistance: true,
|
||||||
|
clearEstimatedDuration: true,
|
||||||
|
isCalculatingRoute: false,
|
||||||
|
clearRouteCalculationError: true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final seq = ++_calculationSequence;
|
||||||
|
state = state.copyWith(
|
||||||
|
isCalculatingRoute: true,
|
||||||
|
clearRouteCalculationError: true,
|
||||||
|
geometry: const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final provider = ref.read(activeRouteCalculationProvider);
|
||||||
|
final result = await provider.calculateRoute(state.stops);
|
||||||
|
if (seq != _calculationSequence) return;
|
||||||
|
state = state.copyWith(
|
||||||
|
geometry: result.geometry,
|
||||||
|
estimatedDistanceMeters: result.estimatedDistanceMeters,
|
||||||
|
estimatedDurationSeconds: result.estimatedDurationSeconds,
|
||||||
|
isCalculatingRoute: false,
|
||||||
|
clearRouteCalculationError: true,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (seq != _calculationSequence) return;
|
||||||
|
final err = e.toString()
|
||||||
|
.replaceAll('RouteProviderException: ', '')
|
||||||
|
.replaceAll('Exception: ', '');
|
||||||
|
state = state.copyWith(
|
||||||
|
geometry: const [], // Stale geometry cleared, no straight-line fallback
|
||||||
|
clearEstimatedDistance: true,
|
||||||
|
clearEstimatedDuration: true,
|
||||||
|
isCalculatingRoute: false,
|
||||||
|
routeCalculationError: err,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<MotorSignalRoute> saveRoute({
|
Future<MotorSignalRoute> saveRoute({
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,396 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:motorcycle_dash/features/routes/route_planner_screen.dart';
|
||||||
|
import 'package:motorcycle_dash/services/auth/auth_providers.dart';
|
||||||
|
import 'package:motorcycle_dash/services/auth/auth_user.dart';
|
||||||
|
import 'package:motorcycle_dash/services/entitlements/entitlement_providers.dart';
|
||||||
|
import 'package:motorcycle_dash/services/map/unified_map_view.dart';
|
||||||
|
import 'package:motorcycle_dash/services/routes/route_models.dart';
|
||||||
|
import 'package:motorcycle_dash/services/routes/route_providers.dart';
|
||||||
|
|
||||||
|
import 'fake_route_provider.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('RFR-1: Route Ordering & Geometry Unit Tests', () {
|
||||||
|
test('Coordinate validation rejects invalid, NaN, Infinity, and (0,0)', () {
|
||||||
|
expect(RoutePlannerNotifier.isValidCoordinate(37.7749, -122.4194), isTrue);
|
||||||
|
expect(RoutePlannerNotifier.isValidCoordinate(-90.0, -180.0), isTrue);
|
||||||
|
expect(RoutePlannerNotifier.isValidCoordinate(90.0, 180.0), isTrue);
|
||||||
|
|
||||||
|
// Invalid bounds
|
||||||
|
expect(RoutePlannerNotifier.isValidCoordinate(91.0, 0.0), isFalse);
|
||||||
|
expect(RoutePlannerNotifier.isValidCoordinate(-91.0, 0.0), isFalse);
|
||||||
|
expect(RoutePlannerNotifier.isValidCoordinate(0.0, 181.0), isFalse);
|
||||||
|
expect(RoutePlannerNotifier.isValidCoordinate(0.0, -181.0), isFalse);
|
||||||
|
|
||||||
|
// Null / NaN / Infinity
|
||||||
|
expect(RoutePlannerNotifier.isValidCoordinate(null, -122.0), isFalse);
|
||||||
|
expect(RoutePlannerNotifier.isValidCoordinate(37.0, null), isFalse);
|
||||||
|
expect(RoutePlannerNotifier.isValidCoordinate(double.nan, -122.0), isFalse);
|
||||||
|
expect(RoutePlannerNotifier.isValidCoordinate(37.0, double.nan), isFalse);
|
||||||
|
expect(RoutePlannerNotifier.isValidCoordinate(double.infinity, -122.0), isFalse);
|
||||||
|
expect(RoutePlannerNotifier.isValidCoordinate(37.0, double.infinity), isFalse);
|
||||||
|
|
||||||
|
// (0, 0) placeholder
|
||||||
|
expect(RoutePlannerNotifier.isValidCoordinate(0.0, 0.0), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('RoutePlannerNotifier: start + destination triggers road calculation', () async {
|
||||||
|
final fake = FakeRouteProvider();
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
container.listen(routePlannerProvider(null), (_, _) {});
|
||||||
|
final notifier = container.read(routePlannerProvider(null).notifier);
|
||||||
|
|
||||||
|
final origin = RouteStop(
|
||||||
|
id: 'stop-1',
|
||||||
|
displayName: 'Start Location',
|
||||||
|
latitude: 37.7749,
|
||||||
|
longitude: -122.4194,
|
||||||
|
order: 0,
|
||||||
|
type: RouteStopType.origin,
|
||||||
|
);
|
||||||
|
final destination = RouteStop(
|
||||||
|
id: 'stop-2',
|
||||||
|
displayName: 'Destination Location',
|
||||||
|
latitude: 37.3382,
|
||||||
|
longitude: -121.8863,
|
||||||
|
order: 1,
|
||||||
|
type: RouteStopType.destination,
|
||||||
|
);
|
||||||
|
|
||||||
|
notifier.addStop(stop: origin);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
expect(container.read(routePlannerProvider(null)).stops.length, 1);
|
||||||
|
expect(container.read(routePlannerProvider(null)).geometry, isEmpty);
|
||||||
|
|
||||||
|
notifier.addStop(stop: destination);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
final state = container.read(routePlannerProvider(null));
|
||||||
|
expect(state.stops.length, 2);
|
||||||
|
expect(state.geometry.isNotEmpty, isTrue);
|
||||||
|
expect(state.geometry.length, 2);
|
||||||
|
expect(state.geometry.first.latitude, closeTo(37.7749, 0.001));
|
||||||
|
expect(state.geometry.last.latitude, closeTo(37.3382, 0.001));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('RoutePlannerNotifier: start + stop + destination preserves exact canonical order', () async {
|
||||||
|
final fake = FakeRouteProvider();
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
container.listen(routePlannerProvider(null), (_, _) {});
|
||||||
|
final notifier = container.read(routePlannerProvider(null).notifier);
|
||||||
|
|
||||||
|
final origin = RouteStop(
|
||||||
|
id: 'origin',
|
||||||
|
displayName: 'San Francisco',
|
||||||
|
latitude: 37.7749,
|
||||||
|
longitude: -122.4194,
|
||||||
|
order: 0,
|
||||||
|
type: RouteStopType.origin,
|
||||||
|
);
|
||||||
|
final destination = RouteStop(
|
||||||
|
id: 'destination',
|
||||||
|
displayName: 'San Jose',
|
||||||
|
latitude: 37.3382,
|
||||||
|
longitude: -121.8863,
|
||||||
|
order: 1,
|
||||||
|
type: RouteStopType.destination,
|
||||||
|
);
|
||||||
|
|
||||||
|
notifier.addStop(stop: origin);
|
||||||
|
notifier.addStop(stop: destination);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
// Intermediate stop added: Must be placed BEFORE destination, NOT after
|
||||||
|
final intermediate = RouteStop(
|
||||||
|
id: 'mid',
|
||||||
|
displayName: 'Palo Alto',
|
||||||
|
latitude: 37.4419,
|
||||||
|
longitude: -122.1430,
|
||||||
|
order: 2,
|
||||||
|
type: RouteStopType.waypoint,
|
||||||
|
);
|
||||||
|
|
||||||
|
notifier.addStop(stop: intermediate);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
final state = container.read(routePlannerProvider(null));
|
||||||
|
expect(state.stops.length, 3);
|
||||||
|
|
||||||
|
// Canonical order: origin (0) -> intermediate (1) -> destination (2)
|
||||||
|
expect(state.stops[0].id, 'origin');
|
||||||
|
expect(state.stops[0].order, 0);
|
||||||
|
expect(state.stops[0].type, RouteStopType.origin);
|
||||||
|
|
||||||
|
expect(state.stops[1].id, 'mid');
|
||||||
|
expect(state.stops[1].order, 1);
|
||||||
|
expect(state.stops[1].type, RouteStopType.waypoint);
|
||||||
|
|
||||||
|
expect(state.stops[2].id, 'destination');
|
||||||
|
expect(state.stops[2].order, 2);
|
||||||
|
expect(state.stops[2].type, RouteStopType.destination);
|
||||||
|
|
||||||
|
// FakeRouteProvider received ordered stops
|
||||||
|
expect(fake.lastCapturedStops, isNotNull);
|
||||||
|
expect(fake.lastCapturedStops!.map((s) => s.id).toList(), ['origin', 'mid', 'destination']);
|
||||||
|
|
||||||
|
// Geometry has all 3 points in order
|
||||||
|
expect(state.geometry.length, 3);
|
||||||
|
expect(state.geometry[0].latitude, closeTo(37.7749, 0.001));
|
||||||
|
expect(state.geometry[1].latitude, closeTo(37.4419, 0.001));
|
||||||
|
expect(state.geometry[2].latitude, closeTo(37.3382, 0.001));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('RoutePlannerNotifier: multiple stops preserve order', () async {
|
||||||
|
final fake = FakeRouteProvider();
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
container.listen(routePlannerProvider(null), (_, _) {});
|
||||||
|
final notifier = container.read(routePlannerProvider(null).notifier);
|
||||||
|
|
||||||
|
notifier.addStop(stop: const RouteStop(id: 'start', displayName: 'Start', latitude: 37.0, longitude: -122.0, order: 0, type: RouteStopType.origin));
|
||||||
|
notifier.addStop(stop: const RouteStop(id: 'end', displayName: 'End', latitude: 37.9, longitude: -122.9, order: 1, type: RouteStopType.destination));
|
||||||
|
notifier.addStop(stop: const RouteStop(id: 'stop1', displayName: 'Stop 1', latitude: 37.2, longitude: -122.2, order: 2, type: RouteStopType.waypoint));
|
||||||
|
notifier.addStop(stop: const RouteStop(id: 'stop2', displayName: 'Stop 2', latitude: 37.4, longitude: -122.4, order: 3, type: RouteStopType.waypoint));
|
||||||
|
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
final stops = container.read(routePlannerProvider(null)).stops;
|
||||||
|
expect(stops.length, 4);
|
||||||
|
expect(stops.map((s) => s.id).toList(), ['start', 'stop1', 'stop2', 'end']);
|
||||||
|
for (int i = 0; i < stops.length; i++) {
|
||||||
|
expect(stops[i].order, i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('RoutePlannerNotifier: stop addition invalidates/replaces stale geometry', () async {
|
||||||
|
final fake = FakeRouteProvider();
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
container.listen(routePlannerProvider(null), (_, _) {});
|
||||||
|
final notifier = container.read(routePlannerProvider(null).notifier);
|
||||||
|
notifier.addStop(stop: const RouteStop(id: 'A', displayName: 'A', latitude: 37.0, longitude: -122.0, order: 0, type: RouteStopType.origin));
|
||||||
|
notifier.addStop(stop: const RouteStop(id: 'B', displayName: 'B', latitude: 38.0, longitude: -122.0, order: 1, type: RouteStopType.destination));
|
||||||
|
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
expect(container.read(routePlannerProvider(null)).geometry.length, 2);
|
||||||
|
|
||||||
|
// Adding another stop must update geometry to length 3
|
||||||
|
notifier.addStop(stop: const RouteStop(id: 'C', displayName: 'C', latitude: 37.5, longitude: -122.0, order: 2, type: RouteStopType.waypoint));
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
expect(container.read(routePlannerProvider(null)).geometry.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('RoutePlannerNotifier: route calculation failure clears stale geometry and sets error', () async {
|
||||||
|
final fake = FakeRouteProvider(shouldFail: true);
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
container.listen(routePlannerProvider(null), (_, _) {});
|
||||||
|
final notifier = container.read(routePlannerProvider(null).notifier);
|
||||||
|
notifier.addStop(stop: const RouteStop(id: 'A', displayName: 'A', latitude: 37.0, longitude: -122.0, order: 0, type: RouteStopType.origin));
|
||||||
|
notifier.addStop(stop: const RouteStop(id: 'B', displayName: 'B', latitude: 38.0, longitude: -122.0, order: 1, type: RouteStopType.destination));
|
||||||
|
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
final state = container.read(routePlannerProvider(null));
|
||||||
|
// Stale geometry MUST be cleared on failure
|
||||||
|
expect(state.geometry, isEmpty);
|
||||||
|
expect(state.routeCalculationError, isNotNull);
|
||||||
|
expect(state.routeCalculationError, contains('Fake route not found'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('No fallback coordinate contamination: rejected invalid stop', () {
|
||||||
|
final fake = FakeRouteProvider();
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
container.listen(routePlannerProvider(null), (_, _) {});
|
||||||
|
final notifier = container.read(routePlannerProvider(null).notifier);
|
||||||
|
// Try adding NaN or 0,0 stop
|
||||||
|
notifier.addStop(stop: const RouteStop(id: 'bad', displayName: 'Bad Stop', latitude: 0.0, longitude: 0.0, order: 0, type: RouteStopType.waypoint));
|
||||||
|
|
||||||
|
expect(container.read(routePlannerProvider(null)).stops, isEmpty);
|
||||||
|
|
||||||
|
expect(container.read(routePlannerProvider(null)).stops, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('RFR-1: Widget & Theme Tests', () {
|
||||||
|
testWidgets('Renderer receives road geometry polyline when route exists', (tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(412, 892)); // Pixel 8a portrait
|
||||||
|
final fake = FakeRouteProvider();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWith((ref) => const AuthUser(uid: 'alice', isAnonymous: false)),
|
||||||
|
isProSubscriberProvider.overrideWith((ref) => false),
|
||||||
|
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: RoutePlannerScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Initially no polylines rendered
|
||||||
|
final initialMap = tester.widget<UnifiedMapView>(find.byKey(const ValueKey('route_planner_map_compact')));
|
||||||
|
expect(initialMap.polylines, isEmpty);
|
||||||
|
|
||||||
|
// Add two stops
|
||||||
|
await tester.tap(find.text('ADD STOP'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final textFields = find.descendant(of: find.byType(AlertDialog), matching: find.byType(TextField));
|
||||||
|
await tester.enterText(textFields.at(0), 'Start Point');
|
||||||
|
await tester.enterText(textFields.at(1), '37.7749');
|
||||||
|
await tester.enterText(textFields.at(2), '-122.4194');
|
||||||
|
await tester.tap(find.text('ADD'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Add second stop
|
||||||
|
await tester.tap(find.text('ADD STOP'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final textFields2 = find.descendant(of: find.byType(AlertDialog), matching: find.byType(TextField));
|
||||||
|
await tester.enterText(textFields2.at(0), 'End Point');
|
||||||
|
await tester.enterText(textFields2.at(1), '37.3382');
|
||||||
|
await tester.enterText(textFields2.at(2), '-121.8863');
|
||||||
|
await tester.tap(find.text('ADD'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify road geometry polyline was passed to UnifiedMapView
|
||||||
|
final updatedMap = tester.widget<UnifiedMapView>(find.byKey(const ValueKey('route_planner_map_compact')));
|
||||||
|
expect(updatedMap.polylines.length, 1);
|
||||||
|
expect(updatedMap.polylines.first.points.length, 2);
|
||||||
|
expect(updatedMap.polylines.first.points.first.latitude, closeTo(37.7749, 0.001));
|
||||||
|
expect(updatedMap.polylines.first.points.last.latitude, closeTo(37.3382, 0.001));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Light mode controls remain visible with proper contrast', (tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(412, 892));
|
||||||
|
final fake = FakeRouteProvider();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWith((ref) => const AuthUser(uid: 'alice', isAnonymous: false)),
|
||||||
|
isProSubscriberProvider.overrideWith((ref) => false),
|
||||||
|
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: ThemeData.light(),
|
||||||
|
home: const RoutePlannerScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('ROUTE PLANNER'), findsOneWidget);
|
||||||
|
expect(find.text('SAVE'), findsOneWidget);
|
||||||
|
expect(find.text('ADD STOP'), findsOneWidget);
|
||||||
|
expect(find.text('CURRENT GPS'), findsOneWidget);
|
||||||
|
expect(find.text('No stops added yet.'), findsOneWidget);
|
||||||
|
|
||||||
|
// Open Add Stop dialog in light mode
|
||||||
|
await tester.tap(find.text('ADD STOP'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Add Route Stop'), findsOneWidget);
|
||||||
|
expect(find.text('CANCEL'), findsOneWidget);
|
||||||
|
expect(find.text('ADD'), findsOneWidget);
|
||||||
|
|
||||||
|
// Cancel dialog
|
||||||
|
await tester.tap(find.text('CANCEL'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Dark mode controls render properly', (tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(412, 892));
|
||||||
|
final fake = FakeRouteProvider();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWith((ref) => const AuthUser(uid: 'alice', isAnonymous: false)),
|
||||||
|
isProSubscriberProvider.overrideWith((ref) => false),
|
||||||
|
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: ThemeData.dark(),
|
||||||
|
home: const RoutePlannerScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('ROUTE PLANNER'), findsOneWidget);
|
||||||
|
expect(find.text('SAVE'), findsOneWidget);
|
||||||
|
expect(find.text('ADD STOP'), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Pixel 8a portrait has no RenderFlex overflow with multiple stops and route info', (tester) async {
|
||||||
|
// Pixel 8a exact viewport: 412 x 892 dp
|
||||||
|
await tester.binding.setSurfaceSize(const Size(412, 892));
|
||||||
|
final fake = FakeRouteProvider();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWith((ref) => const AuthUser(uid: 'alice', isAnonymous: false)),
|
||||||
|
isProSubscriberProvider.overrideWith((ref) => false),
|
||||||
|
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: RoutePlannerScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final titleField = find.byType(TextField).first;
|
||||||
|
await tester.enterText(titleField, 'Pixel 8a Full Test');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Pixel 8a Full Test'), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue