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;
|
||||
}
|
||||
|
||||
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;
|
||||
setState(() {
|
||||
_isCalculatingRoute = true;
|
||||
_routeCalculationError = null;
|
||||
_calculatedRouteResult = null; // Clear stale geometry immediately
|
||||
});
|
||||
|
||||
final orderedStops = <RouteStop>[
|
||||
|
|
@ -122,6 +133,14 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
|
||||
for (int i = 0; i < _stops.length; 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(
|
||||
id: 'stop_$i',
|
||||
order: i + 1,
|
||||
|
|
@ -267,7 +286,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
_selectedRouteId = route.id;
|
||||
_selectedRouteTitle = route.title;
|
||||
_selectedRouteDistanceMeters = route.totalDistanceMeters;
|
||||
_selectedRouteStopCount = route.stops.length;
|
||||
final ordered = route.orderedStops;
|
||||
_selectedRouteStopCount = ordered.length;
|
||||
|
||||
if (_titleController.text.trim().isEmpty) {
|
||||
_titleController.text = route.title;
|
||||
|
|
@ -277,8 +297,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
_descriptionController.text = route.description;
|
||||
}
|
||||
|
||||
if (route.stops.isNotEmpty) {
|
||||
final origin = route.stops.first;
|
||||
if (ordered.isNotEmpty) {
|
||||
final origin = ordered.first;
|
||||
_meetupPlace = ResolvedPlace(
|
||||
providerId: origin.id,
|
||||
displayName: origin.displayName,
|
||||
|
|
@ -291,8 +311,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
_meetupNotesController.text = origin.notes;
|
||||
}
|
||||
|
||||
if (route.stops.length > 1) {
|
||||
final dest = route.stops.last;
|
||||
if (ordered.length > 1) {
|
||||
final dest = ordered.last;
|
||||
_hasDestination = true;
|
||||
_destinationPlace = ResolvedPlace(
|
||||
providerId: dest.id,
|
||||
|
|
@ -304,9 +324,9 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
_destNameController.text = dest.displayName;
|
||||
|
||||
_stops.clear();
|
||||
if (route.stops.length > 2) {
|
||||
for (int i = 1; i < route.stops.length - 1; i++) {
|
||||
final s = route.stops[i];
|
||||
if (ordered.length > 2) {
|
||||
for (int i = 1; i < ordered.length - 1; i++) {
|
||||
final s = ordered[i];
|
||||
_stops.add(
|
||||
GroupRideLocation(
|
||||
latitude: s.latitude,
|
||||
|
|
@ -327,6 +347,9 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
void _showSelectRouteSheet(MotorSignalThemeColors colors) {
|
||||
final user = ref.read(currentUserProvider);
|
||||
if (user == null) return;
|
||||
final theme = Theme.of(context);
|
||||
final onSurface = theme.colorScheme.onSurface;
|
||||
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
|
|
@ -345,18 +368,18 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
Text(
|
||||
'USE EXISTING ROUTE',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
color: onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
Text(
|
||||
'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),
|
||||
routesAsync.when(
|
||||
|
|
@ -373,12 +396,12 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
),
|
||||
data: (routes) {
|
||||
if (routes.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'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),
|
||||
title: Text(
|
||||
route.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
style: TextStyle(
|
||||
color: onSurface,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${route.stops.length} stops • ${route.formattedDistanceMiles}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white54, fontSize: 12),
|
||||
'${route.orderedStops.length} stops • ${route.formattedDistanceMiles}',
|
||||
style: TextStyle(
|
||||
color: onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetCtx);
|
||||
|
|
@ -455,7 +478,12 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setDialogState) => AlertDialog(
|
||||
builder: (ctx, setDialogState) {
|
||||
final theme = Theme.of(ctx);
|
||||
final onSurface = theme.colorScheme.onSurface;
|
||||
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
||||
|
||||
return AlertDialog(
|
||||
backgroundColor: colors.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
|
|
@ -463,7 +491,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
),
|
||||
title: Text(
|
||||
existing != null ? 'Edit Intermediate Stop' : 'Add Intermediate Stop',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(color: onSurface, fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
|
|
@ -486,12 +514,12 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
style: TextStyle(color: onSurface),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Custom Stop Label (Optional)',
|
||||
labelStyle: const TextStyle(color: Colors.white70),
|
||||
labelStyle: TextStyle(color: onSurfaceVariant),
|
||||
hintText: 'e.g. Shell Station / Scenic Overlook',
|
||||
hintStyle: const TextStyle(color: Colors.white24),
|
||||
hintStyle: TextStyle(color: onSurfaceVariant.withValues(alpha: 0.5)),
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainer,
|
||||
border: OutlineInputBorder(
|
||||
|
|
@ -503,7 +531,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
DropdownButtonFormField<GroupRideLocationType>(
|
||||
initialValue: stopType,
|
||||
dropdownColor: colors.surfaceContainerHigh,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
style: TextStyle(color: onSurface),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Stop Type',
|
||||
filled: true,
|
||||
|
|
@ -521,7 +549,10 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
].map((type) {
|
||||
return DropdownMenuItem(
|
||||
value: type,
|
||||
child: Text(type.label),
|
||||
child: Text(
|
||||
type.label,
|
||||
style: TextStyle(color: onSurface),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (val) {
|
||||
|
|
@ -531,12 +562,12 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: notesCtrl,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
style: TextStyle(color: onSurface),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Notes (optional)',
|
||||
labelStyle: const TextStyle(color: Colors.white70),
|
||||
labelStyle: TextStyle(color: onSurfaceVariant),
|
||||
hintText: 'e.g. Park near pumps, 15 min rest',
|
||||
hintStyle: const TextStyle(color: Colors.white24),
|
||||
hintStyle: TextStyle(color: onSurfaceVariant.withValues(alpha: 0.5)),
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainer,
|
||||
border: OutlineInputBorder(
|
||||
|
|
@ -550,7 +581,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('CANCEL', style: TextStyle(color: Colors.white60)),
|
||||
child: Text('CANCEL', style: TextStyle(color: onSurfaceVariant)),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
|
|
@ -558,10 +589,12 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
foregroundColor: Colors.black,
|
||||
),
|
||||
onPressed: () {
|
||||
if (resolved == null) {
|
||||
if (resolved == null ||
|
||||
!RoutePlannerNotifier.isValidCoordinate(
|
||||
resolved!.latitude, resolved!.longitude)) {
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Please select an address from search suggestions.'),
|
||||
content: Text('Please select a valid address with coordinates from search suggestions.'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
|
|
@ -589,7 +622,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
child: Text(existing != null ? 'SAVE STOP' : 'ADD STOP'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,8 +153,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
),
|
||||
title: Text(
|
||||
existingStop != null ? 'Edit Stop' : 'Add Route Stop',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
|
|
@ -193,12 +193,12 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Stop Name *',
|
||||
labelStyle: const TextStyle(color: Colors.white60),
|
||||
labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
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(
|
||||
icon: Icon(Icons.search,
|
||||
color: colors.cyberCyan, size: 20),
|
||||
|
|
@ -226,10 +226,10 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
initialValue: selectedType,
|
||||
isExpanded: true,
|
||||
dropdownColor: colors.surfaceContainerHigh,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Stop Type',
|
||||
labelStyle: const TextStyle(color: Colors.white60),
|
||||
labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
enabledBorder: UnderlineInputBorder(
|
||||
borderSide: BorderSide(color: colors.outlineVariant),
|
||||
),
|
||||
|
|
@ -248,6 +248,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
Expanded(
|
||||
child: Text(
|
||||
type.label,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
|
@ -271,15 +272,15 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
decimal: true,
|
||||
signed: true,
|
||||
),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
fontSize: 13,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Latitude *',
|
||||
labelStyle: TextStyle(color: Colors.white60),
|
||||
labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
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,
|
||||
signed: true,
|
||||
),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
fontSize: 13,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Longitude *',
|
||||
labelStyle: TextStyle(color: Colors.white60),
|
||||
labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
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),
|
||||
TextField(
|
||||
controller: notesCtrl,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
maxLines: 2,
|
||||
decoration: const InputDecoration(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Notes (Optional)',
|
||||
labelStyle: TextStyle(color: Colors.white60),
|
||||
labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
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: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogCtx),
|
||||
child: const Text(
|
||||
child: Text(
|
||||
'CANCEL',
|
||||
style: TextStyle(color: Colors.white54),
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
|
|
@ -346,18 +347,10 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
);
|
||||
return;
|
||||
}
|
||||
if (lat == null || lat < -90.0 || lat > 90.0) {
|
||||
if (!RoutePlannerNotifier.isValidCoordinate(lat, lng)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Invalid latitude (-90 to 90).'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (lng == null || lng < -180.0 || lng > 180.0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Invalid longitude (-180 to 180).'),
|
||||
content: Text('Invalid coordinates: latitude [-90, 90], longitude [-180, 180], non-zero.'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
|
|
@ -370,8 +363,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
notifier.updateStop(
|
||||
existingStop.copyWith(
|
||||
displayName: name,
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
latitude: lat!,
|
||||
longitude: lng!,
|
||||
stopType: selectedType,
|
||||
notes: notesCtrl.text.trim(),
|
||||
),
|
||||
|
|
@ -379,8 +372,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
} else {
|
||||
notifier.addStop(
|
||||
displayName: name,
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
latitude: lat!,
|
||||
longitude: lng!,
|
||||
stopType: selectedType,
|
||||
notes: notesCtrl.text.trim(),
|
||||
);
|
||||
|
|
@ -423,10 +416,10 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
Text(
|
||||
'SEARCH ADDRESS OR PLACE',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
|
|
@ -570,10 +563,12 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
const maxAllowed = RouteService.maxTechnicalStops;
|
||||
final isAtLimit = state.stops.length >= maxAllowed;
|
||||
|
||||
// Center map on first stop or default
|
||||
final mapCenter = state.stops.isNotEmpty
|
||||
// Center map on road geometry, first stop, or default
|
||||
final mapCenter = state.geometry.isNotEmpty
|
||||
? state.geometry.first
|
||||
: (state.stops.isNotEmpty
|
||||
? AppLatLng(state.stops.first.latitude, state.stops.first.longitude)
|
||||
: const AppLatLng(37.7749, -122.4194);
|
||||
: const AppLatLng(37.7749, -122.4194));
|
||||
|
||||
// Build markers
|
||||
final markers = state.stops.map((stop) {
|
||||
|
|
@ -618,19 +613,18 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
);
|
||||
}).toList();
|
||||
|
||||
// Build polylines
|
||||
final polylines = state.stops.length >= 2
|
||||
? [
|
||||
// Build polylines from authoritative calculated road geometry
|
||||
final polylines = <AppPolyline>[];
|
||||
if (state.geometry.isNotEmpty) {
|
||||
polylines.add(
|
||||
AppPolyline(
|
||||
id: 'straight_line_preview',
|
||||
points: state.previewPoints
|
||||
.map((p) => AppLatLng(p.latitude, p.longitude))
|
||||
.toList(),
|
||||
id: 'route_planner_road_geometry',
|
||||
points: state.geometry,
|
||||
color: colors.cyberCyan,
|
||||
width: 3.5,
|
||||
),
|
||||
]
|
||||
: <AppPolyline>[];
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surfaceContainer,
|
||||
|
|
@ -638,12 +632,13 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
backgroundColor: colors.surfaceContainerHigh,
|
||||
elevation: 0,
|
||||
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(),
|
||||
),
|
||||
title: Text(
|
||||
widget.initialRouteId != null ? 'EDIT ROUTE' : 'ROUTE PLANNER',
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 1.2,
|
||||
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(
|
||||
width: double.infinity,
|
||||
color: colors.raceRed.withValues(alpha: 0.15),
|
||||
|
|
@ -925,7 +923,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
effectiveError,
|
||||
style: TextStyle(
|
||||
color: colors.raceRed,
|
||||
fontSize: 12,
|
||||
|
|
@ -933,9 +931,9 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
icon: Icon(
|
||||
Icons.close,
|
||||
color: Colors.white54,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
size: 16,
|
||||
),
|
||||
onPressed: () => setState(() => _errorMessage = null),
|
||||
|
|
@ -954,6 +952,20 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
bool isAtLimit,
|
||||
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(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
|
|
@ -969,9 +981,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
state.stops.length >= 2
|
||||
? 'Straight-line: ${state.formattedDistanceMiles} (${state.formattedDistanceKm})'
|
||||
: 'Add 2+ stops for distance',
|
||||
distanceText,
|
||||
style: TextStyle(
|
||||
color: colors.cyberCyan,
|
||||
fontSize: isCompactHeight ? 11 : 12,
|
||||
|
|
@ -1007,33 +1017,38 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
}
|
||||
|
||||
Widget _buildDisclaimerText() {
|
||||
final theme = Theme.of(context);
|
||||
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,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.65),
|
||||
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.75),
|
||||
fontSize: 9,
|
||||
shadows: const [
|
||||
Shadow(color: Colors.black, blurRadius: 4),
|
||||
Shadow(color: Colors.black26, blurRadius: 4),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRouteInfoInputs(MotorSignalThemeColors colors, bool isCompactHeight) {
|
||||
final theme = Theme.of(context);
|
||||
final onSurface = theme.colorScheme.onSurface;
|
||||
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _titleController,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
style: TextStyle(
|
||||
color: onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Route Title (e.g. Skyline Ridge Loop) *',
|
||||
hintStyle: const TextStyle(
|
||||
color: Colors.white30,
|
||||
hintStyle: TextStyle(
|
||||
color: onSurfaceVariant.withValues(alpha: 0.5),
|
||||
fontSize: 14,
|
||||
),
|
||||
isDense: true,
|
||||
|
|
@ -1060,10 +1075,10 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.visibility_outlined,
|
||||
size: 14,
|
||||
color: Colors.white54,
|
||||
color: onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
|
|
@ -1087,8 +1102,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
value: v,
|
||||
child: Text(
|
||||
v.label,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
style: TextStyle(
|
||||
color: onSurface,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
|
|
@ -1164,14 +1179,17 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
onPressed: () => ref
|
||||
.read(routePlannerNotifierProvider.notifier)
|
||||
.reverseStops(),
|
||||
icon: const Icon(
|
||||
icon: Icon(
|
||||
Icons.swap_vert,
|
||||
size: 16,
|
||||
color: Colors.white70,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
label: const Text(
|
||||
label: Text(
|
||||
'REVERSE',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 11),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
|
|
@ -1192,6 +1210,10 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
bool isCompactHeight,
|
||||
bool isVeryCompactHeight,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
final onSurface = theme.colorScheme.onSurface;
|
||||
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
||||
|
||||
if (state.stops.isEmpty) {
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
|
|
@ -1205,22 +1227,22 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
Icon(
|
||||
Icons.alt_route,
|
||||
size: isCompactHeight ? 32 : 48,
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
color: onSurfaceVariant.withValues(alpha: 0.2),
|
||||
),
|
||||
SizedBox(height: isCompactHeight ? 6 : 12),
|
||||
],
|
||||
const Text(
|
||||
Text(
|
||||
'No stops added yet.',
|
||||
style: TextStyle(
|
||||
color: Colors.white54,
|
||||
color: onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
Text(
|
||||
'Tap "Add Stop" or "Current GPS" to begin planning.',
|
||||
style: TextStyle(
|
||||
color: Colors.white30,
|
||||
color: onSurfaceVariant.withValues(alpha: 0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
|
|
@ -1300,8 +1322,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
Flexible(
|
||||
child: Text(
|
||||
stop.displayName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
style: TextStyle(
|
||||
color: onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
|
|
@ -1313,8 +1335,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${stop.latitude.toStringAsFixed(4)}, ${stop.longitude.toStringAsFixed(4)}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white38,
|
||||
style: TextStyle(
|
||||
color: onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
|
|
@ -1322,8 +1344,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
const SizedBox(height: 2),
|
||||
Text(
|
||||
stop.notes,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
style: TextStyle(
|
||||
color: onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
|
|
@ -1356,9 +1378,9 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
icon: Icon(
|
||||
Icons.edit,
|
||||
color: Colors.white60,
|
||||
color: onSurfaceVariant,
|
||||
size: 18,
|
||||
),
|
||||
tooltip: 'Edit stop',
|
||||
|
|
@ -1390,9 +1412,9 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.drag_handle,
|
||||
color: Colors.white30,
|
||||
color: onSurfaceVariant.withValues(alpha: 0.4),
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -288,6 +288,9 @@ class _AddressAutocompleteFieldState
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.motorColors;
|
||||
final theme = Theme.of(context);
|
||||
final onSurface = theme.colorScheme.onSurface;
|
||||
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
||||
final isResolved = _resolvedPlace != null;
|
||||
|
||||
return Column(
|
||||
|
|
@ -299,7 +302,7 @@ class _AddressAutocompleteFieldState
|
|||
focusNode: _focusNode,
|
||||
enabled: widget.enabled && !_isResolving,
|
||||
autofocus: widget.autofocus,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14),
|
||||
style: TextStyle(color: onSurface, fontSize: 14),
|
||||
onChanged: _onTextChanged,
|
||||
validator: (val) {
|
||||
if (widget.isRequired && (val == null || val.trim().isEmpty)) {
|
||||
|
|
@ -317,10 +320,10 @@ class _AddressAutocompleteFieldState
|
|||
labelText: widget.labelText,
|
||||
hintText: widget.hintText ?? 'Type address or place name...',
|
||||
labelStyle: TextStyle(
|
||||
color: isResolved ? colors.launchGreen : Colors.white70,
|
||||
color: isResolved ? colors.launchGreen : onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
hintStyle: const TextStyle(color: Colors.white24, fontSize: 13),
|
||||
hintStyle: TextStyle(color: onSurfaceVariant.withValues(alpha: 0.5), fontSize: 13),
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainerHigh,
|
||||
prefixIcon:
|
||||
|
|
@ -346,10 +349,10 @@ class _AddressAutocompleteFieldState
|
|||
)
|
||||
: _controller.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(
|
||||
icon: Icon(
|
||||
Icons.clear,
|
||||
size: 18,
|
||||
color: Colors.white54,
|
||||
color: onSurfaceVariant,
|
||||
),
|
||||
onPressed: _clear,
|
||||
tooltip: 'Clear location',
|
||||
|
|
@ -391,6 +394,10 @@ class _AddressAutocompleteFieldState
|
|||
}
|
||||
|
||||
Widget _buildDropdownPanel(MotorSignalThemeColors colors) {
|
||||
final theme = Theme.of(context);
|
||||
final onSurface = theme.colorScheme.onSurface;
|
||||
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
||||
|
||||
return Material(
|
||||
color: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
|
|
@ -423,9 +430,9 @@ class _AddressAutocompleteFieldState
|
|||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
Text(
|
||||
'Searching addresses...',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 12),
|
||||
style: TextStyle(color: onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -444,8 +451,8 @@ class _AddressAutocompleteFieldState
|
|||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
style: TextStyle(
|
||||
color: onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
|
|
@ -465,27 +472,27 @@ class _AddressAutocompleteFieldState
|
|||
),
|
||||
)
|
||||
else if (_hasSearched && _suggestions.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.location_off,
|
||||
color: Colors.white38,
|
||||
color: onSurfaceVariant.withValues(alpha: 0.5),
|
||||
size: 16,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'No matching addresses found',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 12),
|
||||
style: TextStyle(color: onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
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(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
|
|
@ -499,8 +506,8 @@ class _AddressAutocompleteFieldState
|
|||
),
|
||||
title: Text(
|
||||
_suggestions[i].primaryText,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
style: TextStyle(
|
||||
color: onSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
|
|
@ -510,8 +517,8 @@ class _AddressAutocompleteFieldState
|
|||
subtitle: _suggestions[i].secondaryText.isNotEmpty
|
||||
? Text(
|
||||
_suggestions[i].secondaryText,
|
||||
style: const TextStyle(
|
||||
color: Colors.white54,
|
||||
style: TextStyle(
|
||||
color: onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
),
|
||||
maxLines: 1,
|
||||
|
|
@ -528,10 +535,10 @@ class _AddressAutocompleteFieldState
|
|||
vertical: 4,
|
||||
),
|
||||
color: colors.surfaceContainer,
|
||||
child: const Text(
|
||||
child: Text(
|
||||
'Search by Stadia Maps / Pelias • © OpenStreetMap contributors',
|
||||
style: TextStyle(
|
||||
color: Colors.white38,
|
||||
color: onSurfaceVariant.withValues(alpha: 0.6),
|
||||
fontSize: 9,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -64,6 +64,11 @@ class RoutePlannerState {
|
|||
final RouteVisibility visibility;
|
||||
final List<RouteStop> stops;
|
||||
final double straightLineDistanceMeters;
|
||||
final List<AppLatLng> geometry;
|
||||
final double? estimatedDistanceMeters;
|
||||
final int? estimatedDurationSeconds;
|
||||
final bool isCalculatingRoute;
|
||||
final String? routeCalculationError;
|
||||
final bool isSaving;
|
||||
final String? errorMessage;
|
||||
final bool isDirty;
|
||||
|
|
@ -76,6 +81,11 @@ class RoutePlannerState {
|
|||
this.visibility = RouteVisibility.private,
|
||||
this.stops = const [],
|
||||
this.straightLineDistanceMeters = 0.0,
|
||||
this.geometry = const [],
|
||||
this.estimatedDistanceMeters,
|
||||
this.estimatedDurationSeconds,
|
||||
this.isCalculatingRoute = false,
|
||||
this.routeCalculationError,
|
||||
this.isSaving = false,
|
||||
this.errorMessage,
|
||||
this.isDirty = false,
|
||||
|
|
@ -84,13 +94,38 @@ class RoutePlannerState {
|
|||
|
||||
double get distanceMiles => straightLineDistanceMeters * 0.000621371;
|
||||
|
||||
String get formattedDistance =>
|
||||
'${distanceMiles.toStringAsFixed(1)} mi (Straight-line)';
|
||||
double? get roadDistanceMiles =>
|
||||
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 =>
|
||||
'${(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();
|
||||
|
||||
|
|
@ -101,8 +136,17 @@ class RoutePlannerState {
|
|||
RouteVisibility? visibility,
|
||||
List<RouteStop>? stops,
|
||||
double? straightLineDistanceMeters,
|
||||
List<AppLatLng>? geometry,
|
||||
double? estimatedDistanceMeters,
|
||||
bool clearEstimatedDistance = false,
|
||||
int? estimatedDurationSeconds,
|
||||
bool clearEstimatedDuration = false,
|
||||
bool? isCalculatingRoute,
|
||||
String? routeCalculationError,
|
||||
bool clearRouteCalculationError = false,
|
||||
bool? isSaving,
|
||||
String? errorMessage,
|
||||
bool clearErrorMessage = false,
|
||||
bool? isDirty,
|
||||
DateTime? createdAt,
|
||||
}) {
|
||||
|
|
@ -114,8 +158,19 @@ class RoutePlannerState {
|
|||
stops: stops ?? this.stops,
|
||||
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,
|
||||
errorMessage: errorMessage,
|
||||
errorMessage: clearErrorMessage ? null : (errorMessage ?? this.errorMessage),
|
||||
isDirty: isDirty ?? this.isDirty,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
|
|
@ -125,6 +180,7 @@ class RoutePlannerState {
|
|||
/// Controller managing in-memory route editing state.
|
||||
class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
|
||||
final Ref ref;
|
||||
int _calculationSequence = 0;
|
||||
|
||||
RoutePlannerNotifier(this.ref, [MotorSignalRoute? initialRoute])
|
||||
: super(
|
||||
|
|
@ -140,9 +196,23 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
|
|||
createdAt: initialRoute.createdAt,
|
||||
)
|
||||
: 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) {
|
||||
_calculationSequence++;
|
||||
state = RoutePlannerState(
|
||||
routeId: route.id,
|
||||
title: route.title,
|
||||
|
|
@ -153,9 +223,13 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
|
|||
createdAt: route.createdAt,
|
||||
isDirty: false,
|
||||
);
|
||||
if (route.orderedStops.length >= 2) {
|
||||
_calculateRoadRoute();
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_calculationSequence++;
|
||||
state = const RoutePlannerState();
|
||||
}
|
||||
|
||||
|
|
@ -179,56 +253,124 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
|
|||
RouteStopType? stopType,
|
||||
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 effectiveOrder = updatedStops.length;
|
||||
|
||||
final newStop = stop != null
|
||||
? stop.copyWith(order: effectiveOrder, orderIndex: effectiveOrder)
|
||||
? stop.copyWith(
|
||||
latitude: effectiveLat!,
|
||||
longitude: effectiveLng!,
|
||||
type: effectiveType,
|
||||
stopType: effectiveType,
|
||||
)
|
||||
: RouteStop(
|
||||
id: 'stop_${DateTime.now().millisecondsSinceEpoch}_$effectiveOrder',
|
||||
order: effectiveOrder,
|
||||
orderIndex: effectiveOrder,
|
||||
type: stopType ?? RouteStopType.waypoint,
|
||||
stopType: stopType ?? RouteStopType.waypoint,
|
||||
displayName: displayName ?? 'Stop $effectiveOrder',
|
||||
latitude: latitude ?? 0.0,
|
||||
longitude: longitude ?? 0.0,
|
||||
id: 'stop_${DateTime.now().millisecondsSinceEpoch}_${updatedStops.length}',
|
||||
order: updatedStops.length,
|
||||
orderIndex: updatedStops.length,
|
||||
type: effectiveType,
|
||||
stopType: effectiveType,
|
||||
displayName: displayName ?? 'Stop ${updatedStops.length + 1}',
|
||||
latitude: effectiveLat!,
|
||||
longitude: effectiveLng!,
|
||||
notes: notes ?? '',
|
||||
);
|
||||
|
||||
// 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 =
|
||||
MotorSignalRoute.calculateTotalDistanceMeters(updatedStops);
|
||||
// Re-index all stops
|
||||
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(
|
||||
stops: updatedStops,
|
||||
stops: reindexed,
|
||||
straightLineDistanceMeters: distance,
|
||||
geometry: const [], // Invalidate stale geometry immediately!
|
||||
clearEstimatedDistance: true,
|
||||
clearEstimatedDuration: true,
|
||||
isCalculatingRoute: reindexed.length >= 2,
|
||||
clearRouteCalculationError: true,
|
||||
isDirty: true,
|
||||
errorMessage: null,
|
||||
clearErrorMessage: true,
|
||||
);
|
||||
|
||||
if (reindexed.length >= 2) {
|
||||
_calculateRoadRoute();
|
||||
}
|
||||
}
|
||||
|
||||
void updateStop(dynamic target, [RouteStop? maybeStop]) {
|
||||
final updatedStops = List<RouteStop>.from(state.stops);
|
||||
int targetIdx = -1;
|
||||
RouteStop updated;
|
||||
|
||||
if (target is RouteStop) {
|
||||
final idx = updatedStops.indexWhere((s) => s.id == target.id);
|
||||
if (idx == -1) return;
|
||||
updatedStops[idx] = target.copyWith(order: idx, orderIndex: idx);
|
||||
targetIdx = updatedStops.indexWhere((s) => s.id == target.id);
|
||||
if (targetIdx == -1) return;
|
||||
updated = target;
|
||||
} else if (target is int && maybeStop != null) {
|
||||
if (target < 0 || target >= updatedStops.length) return;
|
||||
updatedStops[target] = maybeStop.copyWith(order: target, orderIndex: target);
|
||||
targetIdx = target;
|
||||
updated = maybeStop;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
final distance =
|
||||
MotorSignalRoute.calculateTotalDistanceMeters(updatedStops);
|
||||
if (!isValidCoordinate(updated.latitude, updated.longitude)) {
|
||||
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(
|
||||
stops: updatedStops,
|
||||
straightLineDistanceMeters: distance,
|
||||
geometry: const [], // Invalidate stale geometry
|
||||
clearEstimatedDistance: true,
|
||||
clearEstimatedDuration: true,
|
||||
isCalculatingRoute: updatedStops.length >= 2,
|
||||
clearRouteCalculationError: true,
|
||||
isDirty: true,
|
||||
errorMessage: null,
|
||||
clearErrorMessage: true,
|
||||
);
|
||||
|
||||
if (updatedStops.length >= 2) {
|
||||
_calculateRoadRoute();
|
||||
}
|
||||
}
|
||||
|
||||
void removeStop(dynamic target) {
|
||||
|
|
@ -242,7 +384,6 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
|
|||
return;
|
||||
}
|
||||
|
||||
// Re-index remaining stops
|
||||
final reindexed = <RouteStop>[];
|
||||
for (int i = 0; i < updatedStops.length; i++) {
|
||||
reindexed.add(updatedStops[i].copyWith(order: i, orderIndex: i));
|
||||
|
|
@ -252,9 +393,18 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
|
|||
state = state.copyWith(
|
||||
stops: reindexed,
|
||||
straightLineDistanceMeters: distance,
|
||||
geometry: const [], // Invalidate stale geometry
|
||||
clearEstimatedDistance: true,
|
||||
clearEstimatedDuration: true,
|
||||
isCalculatingRoute: reindexed.length >= 2,
|
||||
clearRouteCalculationError: true,
|
||||
isDirty: true,
|
||||
errorMessage: null,
|
||||
clearErrorMessage: true,
|
||||
);
|
||||
|
||||
if (reindexed.length >= 2) {
|
||||
_calculateRoadRoute();
|
||||
}
|
||||
}
|
||||
|
||||
void reorderStops(int oldIndex, int newIndex) {
|
||||
|
|
@ -275,9 +425,18 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
|
|||
state = state.copyWith(
|
||||
stops: reindexed,
|
||||
straightLineDistanceMeters: distance,
|
||||
geometry: const [], // Invalidate stale geometry
|
||||
clearEstimatedDistance: true,
|
||||
clearEstimatedDuration: true,
|
||||
isCalculatingRoute: reindexed.length >= 2,
|
||||
clearRouteCalculationError: true,
|
||||
isDirty: true,
|
||||
errorMessage: null,
|
||||
clearErrorMessage: true,
|
||||
);
|
||||
|
||||
if (reindexed.length >= 2) {
|
||||
_calculateRoadRoute();
|
||||
}
|
||||
}
|
||||
|
||||
void reverseStops() {
|
||||
|
|
@ -292,9 +451,65 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
|
|||
state = state.copyWith(
|
||||
stops: reindexed,
|
||||
straightLineDistanceMeters: distance,
|
||||
geometry: const [], // Invalidate stale geometry
|
||||
clearEstimatedDistance: true,
|
||||
clearEstimatedDuration: true,
|
||||
isCalculatingRoute: reindexed.length >= 2,
|
||||
clearRouteCalculationError: 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({
|
||||
|
|
|
|||
|
|
@ -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