fix: repair group ride routing and route planner

This commit is contained in:
Developer 2026-09-09 22:08:01 -04:00
parent 58193f6de4
commit 661cd07124
5 changed files with 973 additions and 299 deletions

View File

@ -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'),
),
],
),
);
},
),
);
}

View File

@ -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,
),
],

View File

@ -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,
),

View File

@ -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({

396
test/rfr1_routing_test.dart Normal file
View File

@ -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);
});
});
}