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; return;
} }
if (!RoutePlannerNotifier.isValidCoordinate(_meetupPlace!.latitude, _meetupPlace!.longitude) ||
!RoutePlannerNotifier.isValidCoordinate(_destinationPlace!.latitude, _destinationPlace!.longitude)) {
setState(() {
_calculatedRouteResult = null;
_routeCalculationError = 'Invalid start or destination coordinates.';
_isCalculatingRoute = false;
});
return;
}
final seq = ++_calculationSequence; final seq = ++_calculationSequence;
setState(() { setState(() {
_isCalculatingRoute = true; _isCalculatingRoute = true;
_routeCalculationError = null; _routeCalculationError = null;
_calculatedRouteResult = null; // Clear stale geometry immediately
}); });
final orderedStops = <RouteStop>[ final orderedStops = <RouteStop>[
@ -122,6 +133,14 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
for (int i = 0; i < _stops.length; i++) { for (int i = 0; i < _stops.length; i++) {
final s = _stops[i]; final s = _stops[i];
if (!RoutePlannerNotifier.isValidCoordinate(s.latitude, s.longitude)) {
setState(() {
_isCalculatingRoute = false;
_calculatedRouteResult = null;
_routeCalculationError = 'Stop ${i + 1} has invalid coordinates.';
});
return;
}
orderedStops.add(RouteStop( orderedStops.add(RouteStop(
id: 'stop_$i', id: 'stop_$i',
order: i + 1, order: i + 1,
@ -267,7 +286,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
_selectedRouteId = route.id; _selectedRouteId = route.id;
_selectedRouteTitle = route.title; _selectedRouteTitle = route.title;
_selectedRouteDistanceMeters = route.totalDistanceMeters; _selectedRouteDistanceMeters = route.totalDistanceMeters;
_selectedRouteStopCount = route.stops.length; final ordered = route.orderedStops;
_selectedRouteStopCount = ordered.length;
if (_titleController.text.trim().isEmpty) { if (_titleController.text.trim().isEmpty) {
_titleController.text = route.title; _titleController.text = route.title;
@ -277,8 +297,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
_descriptionController.text = route.description; _descriptionController.text = route.description;
} }
if (route.stops.isNotEmpty) { if (ordered.isNotEmpty) {
final origin = route.stops.first; final origin = ordered.first;
_meetupPlace = ResolvedPlace( _meetupPlace = ResolvedPlace(
providerId: origin.id, providerId: origin.id,
displayName: origin.displayName, displayName: origin.displayName,
@ -291,8 +311,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
_meetupNotesController.text = origin.notes; _meetupNotesController.text = origin.notes;
} }
if (route.stops.length > 1) { if (ordered.length > 1) {
final dest = route.stops.last; final dest = ordered.last;
_hasDestination = true; _hasDestination = true;
_destinationPlace = ResolvedPlace( _destinationPlace = ResolvedPlace(
providerId: dest.id, providerId: dest.id,
@ -304,9 +324,9 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
_destNameController.text = dest.displayName; _destNameController.text = dest.displayName;
_stops.clear(); _stops.clear();
if (route.stops.length > 2) { if (ordered.length > 2) {
for (int i = 1; i < route.stops.length - 1; i++) { for (int i = 1; i < ordered.length - 1; i++) {
final s = route.stops[i]; final s = ordered[i];
_stops.add( _stops.add(
GroupRideLocation( GroupRideLocation(
latitude: s.latitude, latitude: s.latitude,
@ -327,6 +347,9 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
void _showSelectRouteSheet(MotorSignalThemeColors colors) { void _showSelectRouteSheet(MotorSignalThemeColors colors) {
final user = ref.read(currentUserProvider); final user = ref.read(currentUserProvider);
if (user == null) return; if (user == null) return;
final theme = Theme.of(context);
final onSurface = theme.colorScheme.onSurface;
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
@ -345,18 +368,18 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( Text(
'USE EXISTING ROUTE', 'USE EXISTING ROUTE',
style: TextStyle( style: TextStyle(
color: Colors.white, color: onSurface,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 16, fontSize: 16,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text( Text(
'Pre-fills title, meetup location, intermediate waypoints, and destination.', 'Pre-fills title, meetup location, intermediate waypoints, and destination.',
style: TextStyle(color: Colors.white54, fontSize: 12), style: TextStyle(color: onSurfaceVariant, fontSize: 12),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
routesAsync.when( routesAsync.when(
@ -373,12 +396,12 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
), ),
data: (routes) { data: (routes) {
if (routes.isEmpty) { if (routes.isEmpty) {
return const Padding( return Padding(
padding: EdgeInsets.symmetric(vertical: 24), padding: const EdgeInsets.symmetric(vertical: 24),
child: Center( child: Center(
child: Text( child: Text(
'No saved routes found.', 'No saved routes found.',
style: TextStyle(color: Colors.white54), style: TextStyle(color: onSurfaceVariant),
), ),
), ),
); );
@ -397,14 +420,14 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
color: colors.cyberCyan), color: colors.cyberCyan),
title: Text( title: Text(
route.title, route.title,
style: const TextStyle( style: TextStyle(
color: Colors.white, color: onSurface,
fontWeight: FontWeight.bold), fontWeight: FontWeight.bold),
), ),
subtitle: Text( subtitle: Text(
'${route.stops.length} stops • ${route.formattedDistanceMiles}', '${route.orderedStops.length} stops • ${route.formattedDistanceMiles}',
style: const TextStyle( style: TextStyle(
color: Colors.white54, fontSize: 12), color: onSurfaceVariant, fontSize: 12),
), ),
onTap: () { onTap: () {
Navigator.pop(sheetCtx); Navigator.pop(sheetCtx);
@ -455,141 +478,152 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
showDialog( showDialog(
context: context, context: context,
builder: (ctx) => StatefulBuilder( builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog( builder: (ctx, setDialogState) {
backgroundColor: colors.surfaceContainerHigh, final theme = Theme.of(ctx);
shape: RoundedRectangleBorder( final onSurface = theme.colorScheme.onSurface;
borderRadius: BorderRadius.circular(16), final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
side: BorderSide(color: colors.outlineVariant),
), return AlertDialog(
title: Text( backgroundColor: colors.surfaceContainerHigh,
existing != null ? 'Edit Intermediate Stop' : 'Add Intermediate Stop', shape: RoundedRectangleBorder(
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold), borderRadius: BorderRadius.circular(16),
), side: BorderSide(color: colors.outlineVariant),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AddressAutocompleteField(
labelText: 'Stop Address / Location *',
hintText: 'Search address or landmark...',
initialPlace: resolved,
onPlaceResolved: (place) {
setDialogState(() {
resolved = place;
if (place != null && nameCtrl.text.trim().isEmpty) {
nameCtrl.text = place.displayName;
}
});
},
),
const SizedBox(height: 12),
TextField(
controller: nameCtrl,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: 'Custom Stop Label (Optional)',
labelStyle: const TextStyle(color: Colors.white70),
hintText: 'e.g. Shell Station / Scenic Overlook',
hintStyle: const TextStyle(color: Colors.white24),
filled: true,
fillColor: colors.surfaceContainer,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
const SizedBox(height: 12),
DropdownButtonFormField<GroupRideLocationType>(
initialValue: stopType,
dropdownColor: colors.surfaceContainerHigh,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: 'Stop Type',
filled: true,
fillColor: colors.surfaceContainer,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
items: [
GroupRideLocationType.fuel,
GroupRideLocationType.rest,
GroupRideLocationType.food,
GroupRideLocationType.scenic,
GroupRideLocationType.waypoint,
].map((type) {
return DropdownMenuItem(
value: type,
child: Text(type.label),
);
}).toList(),
onChanged: (val) {
if (val != null) setDialogState(() => stopType = val);
},
),
const SizedBox(height: 12),
TextField(
controller: notesCtrl,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: 'Notes (optional)',
labelStyle: const TextStyle(color: Colors.white70),
hintText: 'e.g. Park near pumps, 15 min rest',
hintStyle: const TextStyle(color: Colors.white24),
filled: true,
fillColor: colors.surfaceContainer,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
), ),
), title: Text(
actions: [ existing != null ? 'Edit Intermediate Stop' : 'Add Intermediate Stop',
TextButton( style: TextStyle(color: onSurface, fontSize: 16, fontWeight: FontWeight.bold),
onPressed: () => Navigator.pop(ctx),
child: const Text('CANCEL', style: TextStyle(color: Colors.white60)),
), ),
ElevatedButton( content: SingleChildScrollView(
style: ElevatedButton.styleFrom( child: Column(
backgroundColor: colors.cyberCyan, mainAxisSize: MainAxisSize.min,
foregroundColor: Colors.black, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AddressAutocompleteField(
labelText: 'Stop Address / Location *',
hintText: 'Search address or landmark...',
initialPlace: resolved,
onPlaceResolved: (place) {
setDialogState(() {
resolved = place;
if (place != null && nameCtrl.text.trim().isEmpty) {
nameCtrl.text = place.displayName;
}
});
},
),
const SizedBox(height: 12),
TextField(
controller: nameCtrl,
style: TextStyle(color: onSurface),
decoration: InputDecoration(
labelText: 'Custom Stop Label (Optional)',
labelStyle: TextStyle(color: onSurfaceVariant),
hintText: 'e.g. Shell Station / Scenic Overlook',
hintStyle: TextStyle(color: onSurfaceVariant.withValues(alpha: 0.5)),
filled: true,
fillColor: colors.surfaceContainer,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
const SizedBox(height: 12),
DropdownButtonFormField<GroupRideLocationType>(
initialValue: stopType,
dropdownColor: colors.surfaceContainerHigh,
style: TextStyle(color: onSurface),
decoration: InputDecoration(
labelText: 'Stop Type',
filled: true,
fillColor: colors.surfaceContainer,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
items: [
GroupRideLocationType.fuel,
GroupRideLocationType.rest,
GroupRideLocationType.food,
GroupRideLocationType.scenic,
GroupRideLocationType.waypoint,
].map((type) {
return DropdownMenuItem(
value: type,
child: Text(
type.label,
style: TextStyle(color: onSurface),
),
);
}).toList(),
onChanged: (val) {
if (val != null) setDialogState(() => stopType = val);
},
),
const SizedBox(height: 12),
TextField(
controller: notesCtrl,
style: TextStyle(color: onSurface),
decoration: InputDecoration(
labelText: 'Notes (optional)',
labelStyle: TextStyle(color: onSurfaceVariant),
hintText: 'e.g. Park near pumps, 15 min rest',
hintStyle: TextStyle(color: onSurfaceVariant.withValues(alpha: 0.5)),
filled: true,
fillColor: colors.surfaceContainer,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
), ),
onPressed: () {
if (resolved == null) {
ScaffoldMessenger.of(ctx).showSnackBar(
const SnackBar(
content: Text('Please select an address from search suggestions.'),
),
);
return;
}
final loc = GroupRideLocation(
latitude: resolved!.latitude,
longitude: resolved!.longitude,
displayName: nameCtrl.text.trim().isNotEmpty
? nameCtrl.text.trim()
: resolved!.displayName,
address: resolved!.formattedAddress ?? '',
locationType: stopType,
notes: notesCtrl.text.trim(),
);
setState(() {
if (editIndex != null) {
_stops[editIndex] = loc;
} else {
_stops.add(loc);
}
});
Navigator.pop(ctx);
_triggerRouteCalculation();
},
child: Text(existing != null ? 'SAVE STOP' : 'ADD STOP'),
), ),
], actions: [
), TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text('CANCEL', style: TextStyle(color: onSurfaceVariant)),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black,
),
onPressed: () {
if (resolved == null ||
!RoutePlannerNotifier.isValidCoordinate(
resolved!.latitude, resolved!.longitude)) {
ScaffoldMessenger.of(ctx).showSnackBar(
const SnackBar(
content: Text('Please select a valid address with coordinates from search suggestions.'),
),
);
return;
}
final loc = GroupRideLocation(
latitude: resolved!.latitude,
longitude: resolved!.longitude,
displayName: nameCtrl.text.trim().isNotEmpty
? nameCtrl.text.trim()
: resolved!.displayName,
address: resolved!.formattedAddress ?? '',
locationType: stopType,
notes: notesCtrl.text.trim(),
);
setState(() {
if (editIndex != null) {
_stops[editIndex] = loc;
} else {
_stops.add(loc);
}
});
Navigator.pop(ctx);
_triggerRouteCalculation();
},
child: Text(existing != null ? 'SAVE STOP' : 'ADD STOP'),
),
],
);
},
), ),
); );
} }

View File

@ -153,8 +153,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
), ),
title: Text( title: Text(
existingStop != null ? 'Edit Stop' : 'Add Route Stop', existingStop != null ? 'Edit Stop' : 'Add Route Stop',
style: const TextStyle( style: TextStyle(
color: Colors.white, color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@ -193,12 +193,12 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
controller: nameCtrl, controller: nameCtrl,
style: const TextStyle(color: Colors.white), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Stop Name *', labelText: 'Stop Name *',
labelStyle: const TextStyle(color: Colors.white60), labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
hintText: 'e.g. Skyline Summit, Fuel Stop', hintText: 'e.g. Skyline Summit, Fuel Stop',
hintStyle: const TextStyle(color: Colors.white24), hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)),
suffixIcon: IconButton( suffixIcon: IconButton(
icon: Icon(Icons.search, icon: Icon(Icons.search,
color: colors.cyberCyan, size: 20), color: colors.cyberCyan, size: 20),
@ -226,10 +226,10 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
initialValue: selectedType, initialValue: selectedType,
isExpanded: true, isExpanded: true,
dropdownColor: colors.surfaceContainerHigh, dropdownColor: colors.surfaceContainerHigh,
style: const TextStyle(color: Colors.white), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Stop Type', labelText: 'Stop Type',
labelStyle: const TextStyle(color: Colors.white60), labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
enabledBorder: UnderlineInputBorder( enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: colors.outlineVariant), borderSide: BorderSide(color: colors.outlineVariant),
), ),
@ -248,6 +248,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
Expanded( Expanded(
child: Text( child: Text(
type.label, type.label,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
@ -271,15 +272,15 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
decimal: true, decimal: true,
signed: true, signed: true,
), ),
style: const TextStyle( style: TextStyle(
color: Colors.white, color: Theme.of(context).colorScheme.onSurface,
fontSize: 13, fontSize: 13,
), ),
decoration: const InputDecoration( decoration: InputDecoration(
labelText: 'Latitude *', labelText: 'Latitude *',
labelStyle: TextStyle(color: Colors.white60), labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
hintText: '37.421998', hintText: '37.421998',
hintStyle: TextStyle(color: Colors.white24), hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)),
), ),
), ),
), ),
@ -291,15 +292,15 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
decimal: true, decimal: true,
signed: true, signed: true,
), ),
style: const TextStyle( style: TextStyle(
color: Colors.white, color: Theme.of(context).colorScheme.onSurface,
fontSize: 13, fontSize: 13,
), ),
decoration: const InputDecoration( decoration: InputDecoration(
labelText: 'Longitude *', labelText: 'Longitude *',
labelStyle: TextStyle(color: Colors.white60), labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
hintText: '-122.084000', hintText: '-122.084000',
hintStyle: TextStyle(color: Colors.white24), hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)),
), ),
), ),
), ),
@ -308,13 +309,13 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
controller: notesCtrl, controller: notesCtrl,
style: const TextStyle(color: Colors.white), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
maxLines: 2, maxLines: 2,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: 'Notes (Optional)', labelText: 'Notes (Optional)',
labelStyle: TextStyle(color: Colors.white60), labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
hintText: 'Regroup point, scenic view, cautions...', hintText: 'Regroup point, scenic view, cautions...',
hintStyle: TextStyle(color: Colors.white24), hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)),
), ),
), ),
], ],
@ -323,9 +324,9 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(dialogCtx), onPressed: () => Navigator.pop(dialogCtx),
child: const Text( child: Text(
'CANCEL', 'CANCEL',
style: TextStyle(color: Colors.white54), style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
), ),
), ),
ElevatedButton( ElevatedButton(
@ -346,18 +347,10 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
); );
return; return;
} }
if (lat == null || lat < -90.0 || lat > 90.0) { if (!RoutePlannerNotifier.isValidCoordinate(lat, lng)) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Invalid latitude (-90 to 90).'), content: Text('Invalid coordinates: latitude [-90, 90], longitude [-180, 180], non-zero.'),
),
);
return;
}
if (lng == null || lng < -180.0 || lng > 180.0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Invalid longitude (-180 to 180).'),
), ),
); );
return; return;
@ -370,8 +363,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
notifier.updateStop( notifier.updateStop(
existingStop.copyWith( existingStop.copyWith(
displayName: name, displayName: name,
latitude: lat, latitude: lat!,
longitude: lng, longitude: lng!,
stopType: selectedType, stopType: selectedType,
notes: notesCtrl.text.trim(), notes: notesCtrl.text.trim(),
), ),
@ -379,8 +372,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
} else { } else {
notifier.addStop( notifier.addStop(
displayName: name, displayName: name,
latitude: lat, latitude: lat!,
longitude: lng, longitude: lng!,
stopType: selectedType, stopType: selectedType,
notes: notesCtrl.text.trim(), notes: notesCtrl.text.trim(),
); );
@ -423,10 +416,10 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Text( Text(
'SEARCH ADDRESS OR PLACE', 'SEARCH ADDRESS OR PLACE',
style: TextStyle( style: TextStyle(
color: Colors.white, color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 14, fontSize: 14,
), ),
@ -570,10 +563,12 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
const maxAllowed = RouteService.maxTechnicalStops; const maxAllowed = RouteService.maxTechnicalStops;
final isAtLimit = state.stops.length >= maxAllowed; final isAtLimit = state.stops.length >= maxAllowed;
// Center map on first stop or default // Center map on road geometry, first stop, or default
final mapCenter = state.stops.isNotEmpty final mapCenter = state.geometry.isNotEmpty
? AppLatLng(state.stops.first.latitude, state.stops.first.longitude) ? state.geometry.first
: const AppLatLng(37.7749, -122.4194); : (state.stops.isNotEmpty
? AppLatLng(state.stops.first.latitude, state.stops.first.longitude)
: const AppLatLng(37.7749, -122.4194));
// Build markers // Build markers
final markers = state.stops.map((stop) { final markers = state.stops.map((stop) {
@ -618,19 +613,18 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
); );
}).toList(); }).toList();
// Build polylines // Build polylines from authoritative calculated road geometry
final polylines = state.stops.length >= 2 final polylines = <AppPolyline>[];
? [ if (state.geometry.isNotEmpty) {
AppPolyline( polylines.add(
id: 'straight_line_preview', AppPolyline(
points: state.previewPoints id: 'route_planner_road_geometry',
.map((p) => AppLatLng(p.latitude, p.longitude)) points: state.geometry,
.toList(), color: colors.cyberCyan,
color: colors.cyberCyan, width: 3.5,
width: 3.5, ),
), );
] }
: <AppPolyline>[];
return Scaffold( return Scaffold(
backgroundColor: colors.surfaceContainer, backgroundColor: colors.surfaceContainer,
@ -638,12 +632,13 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
backgroundColor: colors.surfaceContainerHigh, backgroundColor: colors.surfaceContainerHigh,
elevation: 0, elevation: 0,
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white), icon: Icon(Icons.arrow_back, color: Theme.of(context).colorScheme.onSurface),
onPressed: () => context.pop(), onPressed: () => context.pop(),
), ),
title: Text( title: Text(
widget.initialRouteId != null ? 'EDIT ROUTE' : 'ROUTE PLANNER', widget.initialRouteId != null ? 'EDIT ROUTE' : 'ROUTE PLANNER',
style: const TextStyle( style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
letterSpacing: 1.2, letterSpacing: 1.2,
fontSize: 16, fontSize: 16,
@ -907,7 +902,10 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
); );
} }
Widget _buildErrorBanner(MotorSignalThemeColors colors) { Widget _buildErrorBanner(MotorSignalThemeColors colors, [String? message]) {
final effectiveError = message ?? _errorMessage;
if (effectiveError == null) return const SizedBox.shrink();
return Container( return Container(
width: double.infinity, width: double.infinity,
color: colors.raceRed.withValues(alpha: 0.15), color: colors.raceRed.withValues(alpha: 0.15),
@ -925,7 +923,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
_errorMessage!, effectiveError,
style: TextStyle( style: TextStyle(
color: colors.raceRed, color: colors.raceRed,
fontSize: 12, fontSize: 12,
@ -933,9 +931,9 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
), ),
), ),
IconButton( IconButton(
icon: const Icon( icon: Icon(
Icons.close, Icons.close,
color: Colors.white54, color: Theme.of(context).colorScheme.onSurfaceVariant,
size: 16, size: 16,
), ),
onPressed: () => setState(() => _errorMessage = null), onPressed: () => setState(() => _errorMessage = null),
@ -954,6 +952,20 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
bool isAtLimit, bool isAtLimit,
bool isCompactHeight, bool isCompactHeight,
) { ) {
String distanceText;
if (state.stops.length < 2) {
distanceText = 'Add 2+ stops for route';
} else if (state.isCalculatingRoute) {
distanceText = 'Calculating road route...';
} else if (state.routeCalculationError != null) {
distanceText = 'Route calculation failed';
} else if (state.geometry.isNotEmpty && state.estimatedDistanceMeters != null) {
distanceText =
'Road: ${state.formattedRoadDistanceMiles} (${state.formattedRoadDistanceKm})${state.formattedRoadDuration != null ? ' • ${state.formattedRoadDuration}' : ''}';
} else {
distanceText = 'Straight-line: ${state.formattedDistanceMiles} (${state.formattedDistanceKm})';
}
return Container( return Container(
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: 8, horizontal: 8,
@ -969,9 +981,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
state.stops.length >= 2 distanceText,
? 'Straight-line: ${state.formattedDistanceMiles} (${state.formattedDistanceKm})'
: 'Add 2+ stops for distance',
style: TextStyle( style: TextStyle(
color: colors.cyberCyan, color: colors.cyberCyan,
fontSize: isCompactHeight ? 11 : 12, fontSize: isCompactHeight ? 11 : 12,
@ -1007,33 +1017,38 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
} }
Widget _buildDisclaimerText() { Widget _buildDisclaimerText() {
final theme = Theme.of(context);
return Text( return Text(
'Straight-line preview. Navigation handoff launches in Google Maps / Waze.', 'Calculated road route. Navigation handoff launches in Google Maps / Waze.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
color: Colors.white.withValues(alpha: 0.65), color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.75),
fontSize: 9, fontSize: 9,
shadows: const [ shadows: const [
Shadow(color: Colors.black, blurRadius: 4), Shadow(color: Colors.black26, blurRadius: 4),
], ],
), ),
); );
} }
Widget _buildRouteInfoInputs(MotorSignalThemeColors colors, bool isCompactHeight) { Widget _buildRouteInfoInputs(MotorSignalThemeColors colors, bool isCompactHeight) {
final theme = Theme.of(context);
final onSurface = theme.colorScheme.onSurface;
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
return Column( return Column(
children: [ children: [
TextField( TextField(
controller: _titleController, controller: _titleController,
style: const TextStyle( style: TextStyle(
color: Colors.white, color: onSurface,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 15, fontSize: 15,
), ),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Route Title (e.g. Skyline Ridge Loop) *', hintText: 'Route Title (e.g. Skyline Ridge Loop) *',
hintStyle: const TextStyle( hintStyle: TextStyle(
color: Colors.white30, color: onSurfaceVariant.withValues(alpha: 0.5),
fontSize: 14, fontSize: 14,
), ),
isDense: true, isDense: true,
@ -1060,10 +1075,10 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon( Icon(
Icons.visibility_outlined, Icons.visibility_outlined,
size: 14, size: 14,
color: Colors.white54, color: onSurfaceVariant,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
@ -1087,8 +1102,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
value: v, value: v,
child: Text( child: Text(
v.label, v.label,
style: const TextStyle( style: TextStyle(
color: Colors.white, color: onSurface,
fontSize: 13, fontSize: 13,
), ),
), ),
@ -1164,14 +1179,17 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
onPressed: () => ref onPressed: () => ref
.read(routePlannerNotifierProvider.notifier) .read(routePlannerNotifierProvider.notifier)
.reverseStops(), .reverseStops(),
icon: const Icon( icon: Icon(
Icons.swap_vert, Icons.swap_vert,
size: 16, size: 16,
color: Colors.white70, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
label: const Text( label: Text(
'REVERSE', 'REVERSE',
style: TextStyle(color: Colors.white70, fontSize: 11), style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 11,
),
), ),
style: TextButton.styleFrom( style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@ -1192,6 +1210,10 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
bool isCompactHeight, bool isCompactHeight,
bool isVeryCompactHeight, bool isVeryCompactHeight,
) { ) {
final theme = Theme.of(context);
final onSurface = theme.colorScheme.onSurface;
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
if (state.stops.isEmpty) { if (state.stops.isEmpty) {
return Center( return Center(
child: SingleChildScrollView( child: SingleChildScrollView(
@ -1205,22 +1227,22 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
Icon( Icon(
Icons.alt_route, Icons.alt_route,
size: isCompactHeight ? 32 : 48, size: isCompactHeight ? 32 : 48,
color: Colors.white.withValues(alpha: 0.15), color: onSurfaceVariant.withValues(alpha: 0.2),
), ),
SizedBox(height: isCompactHeight ? 6 : 12), SizedBox(height: isCompactHeight ? 6 : 12),
], ],
const Text( Text(
'No stops added yet.', 'No stops added yet.',
style: TextStyle( style: TextStyle(
color: Colors.white54, color: onSurfaceVariant,
fontSize: 14, fontSize: 14,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
const Text( Text(
'Tap "Add Stop" or "Current GPS" to begin planning.', 'Tap "Add Stop" or "Current GPS" to begin planning.',
style: TextStyle( style: TextStyle(
color: Colors.white30, color: onSurfaceVariant.withValues(alpha: 0.6),
fontSize: 12, fontSize: 12,
), ),
), ),
@ -1300,8 +1322,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
Flexible( Flexible(
child: Text( child: Text(
stop.displayName, stop.displayName,
style: const TextStyle( style: TextStyle(
color: Colors.white, color: onSurface,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 14, fontSize: 14,
), ),
@ -1313,8 +1335,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
'${stop.latitude.toStringAsFixed(4)}, ${stop.longitude.toStringAsFixed(4)}', '${stop.latitude.toStringAsFixed(4)}, ${stop.longitude.toStringAsFixed(4)}',
style: const TextStyle( style: TextStyle(
color: Colors.white38, color: onSurfaceVariant,
fontSize: 11, fontSize: 11,
), ),
), ),
@ -1322,8 +1344,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
stop.notes, stop.notes,
style: const TextStyle( style: TextStyle(
color: Colors.white70, color: onSurfaceVariant,
fontSize: 11, fontSize: 11,
fontStyle: FontStyle.italic, fontStyle: FontStyle.italic,
), ),
@ -1356,9 +1378,9 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
), ),
), ),
IconButton( IconButton(
icon: const Icon( icon: Icon(
Icons.edit, Icons.edit,
color: Colors.white60, color: onSurfaceVariant,
size: 18, size: 18,
), ),
tooltip: 'Edit stop', tooltip: 'Edit stop',
@ -1390,9 +1412,9 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
), ),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
const Icon( Icon(
Icons.drag_handle, Icons.drag_handle,
color: Colors.white30, color: onSurfaceVariant.withValues(alpha: 0.4),
size: 20, size: 20,
), ),
], ],

View File

@ -288,6 +288,9 @@ class _AddressAutocompleteFieldState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.motorColors; final colors = context.motorColors;
final theme = Theme.of(context);
final onSurface = theme.colorScheme.onSurface;
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
final isResolved = _resolvedPlace != null; final isResolved = _resolvedPlace != null;
return Column( return Column(
@ -299,7 +302,7 @@ class _AddressAutocompleteFieldState
focusNode: _focusNode, focusNode: _focusNode,
enabled: widget.enabled && !_isResolving, enabled: widget.enabled && !_isResolving,
autofocus: widget.autofocus, autofocus: widget.autofocus,
style: const TextStyle(color: Colors.white, fontSize: 14), style: TextStyle(color: onSurface, fontSize: 14),
onChanged: _onTextChanged, onChanged: _onTextChanged,
validator: (val) { validator: (val) {
if (widget.isRequired && (val == null || val.trim().isEmpty)) { if (widget.isRequired && (val == null || val.trim().isEmpty)) {
@ -317,10 +320,10 @@ class _AddressAutocompleteFieldState
labelText: widget.labelText, labelText: widget.labelText,
hintText: widget.hintText ?? 'Type address or place name...', hintText: widget.hintText ?? 'Type address or place name...',
labelStyle: TextStyle( labelStyle: TextStyle(
color: isResolved ? colors.launchGreen : Colors.white70, color: isResolved ? colors.launchGreen : onSurfaceVariant,
fontSize: 13, fontSize: 13,
), ),
hintStyle: const TextStyle(color: Colors.white24, fontSize: 13), hintStyle: TextStyle(color: onSurfaceVariant.withValues(alpha: 0.5), fontSize: 13),
filled: true, filled: true,
fillColor: colors.surfaceContainerHigh, fillColor: colors.surfaceContainerHigh,
prefixIcon: prefixIcon:
@ -346,10 +349,10 @@ class _AddressAutocompleteFieldState
) )
: _controller.text.isNotEmpty : _controller.text.isNotEmpty
? IconButton( ? IconButton(
icon: const Icon( icon: Icon(
Icons.clear, Icons.clear,
size: 18, size: 18,
color: Colors.white54, color: onSurfaceVariant,
), ),
onPressed: _clear, onPressed: _clear,
tooltip: 'Clear location', tooltip: 'Clear location',
@ -391,6 +394,10 @@ class _AddressAutocompleteFieldState
} }
Widget _buildDropdownPanel(MotorSignalThemeColors colors) { Widget _buildDropdownPanel(MotorSignalThemeColors colors) {
final theme = Theme.of(context);
final onSurface = theme.colorScheme.onSurface;
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
return Material( return Material(
color: colors.surfaceContainerHighest, color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@ -423,9 +430,9 @@ class _AddressAutocompleteFieldState
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
const Text( Text(
'Searching addresses...', 'Searching addresses...',
style: TextStyle(color: Colors.white70, fontSize: 12), style: TextStyle(color: onSurfaceVariant, fontSize: 12),
), ),
], ],
), ),
@ -444,8 +451,8 @@ class _AddressAutocompleteFieldState
Expanded( Expanded(
child: Text( child: Text(
_errorMessage!, _errorMessage!,
style: const TextStyle( style: TextStyle(
color: Colors.white70, color: onSurfaceVariant,
fontSize: 12, fontSize: 12,
), ),
), ),
@ -465,27 +472,27 @@ class _AddressAutocompleteFieldState
), ),
) )
else if (_hasSearched && _suggestions.isEmpty) else if (_hasSearched && _suggestions.isEmpty)
const Padding( Padding(
padding: EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(
Icons.location_off, Icons.location_off,
color: Colors.white38, color: onSurfaceVariant.withValues(alpha: 0.5),
size: 16, size: 16,
), ),
SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'No matching addresses found', 'No matching addresses found',
style: TextStyle(color: Colors.white54, fontSize: 12), style: TextStyle(color: onSurfaceVariant, fontSize: 12),
), ),
], ],
), ),
) )
else else
for (int i = 0; i < _suggestions.length; i++) ...[ for (int i = 0; i < _suggestions.length; i++) ...[
if (i > 0) const Divider(height: 1, color: Colors.white10), if (i > 0) Divider(height: 1, color: colors.outlineVariant),
ListTile( ListTile(
dense: true, dense: true,
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
@ -499,8 +506,8 @@ class _AddressAutocompleteFieldState
), ),
title: Text( title: Text(
_suggestions[i].primaryText, _suggestions[i].primaryText,
style: const TextStyle( style: TextStyle(
color: Colors.white, color: onSurface,
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@ -510,8 +517,8 @@ class _AddressAutocompleteFieldState
subtitle: _suggestions[i].secondaryText.isNotEmpty subtitle: _suggestions[i].secondaryText.isNotEmpty
? Text( ? Text(
_suggestions[i].secondaryText, _suggestions[i].secondaryText,
style: const TextStyle( style: TextStyle(
color: Colors.white54, color: onSurfaceVariant,
fontSize: 11, fontSize: 11,
), ),
maxLines: 1, maxLines: 1,
@ -528,10 +535,10 @@ class _AddressAutocompleteFieldState
vertical: 4, vertical: 4,
), ),
color: colors.surfaceContainer, color: colors.surfaceContainer,
child: const Text( child: Text(
'Search by Stadia Maps / Pelias • © OpenStreetMap contributors', 'Search by Stadia Maps / Pelias • © OpenStreetMap contributors',
style: TextStyle( style: TextStyle(
color: Colors.white38, color: onSurfaceVariant.withValues(alpha: 0.6),
fontSize: 9, fontSize: 9,
letterSpacing: 0.2, letterSpacing: 0.2,
), ),

View File

@ -64,6 +64,11 @@ class RoutePlannerState {
final RouteVisibility visibility; final RouteVisibility visibility;
final List<RouteStop> stops; final List<RouteStop> stops;
final double straightLineDistanceMeters; final double straightLineDistanceMeters;
final List<AppLatLng> geometry;
final double? estimatedDistanceMeters;
final int? estimatedDurationSeconds;
final bool isCalculatingRoute;
final String? routeCalculationError;
final bool isSaving; final bool isSaving;
final String? errorMessage; final String? errorMessage;
final bool isDirty; final bool isDirty;
@ -76,6 +81,11 @@ class RoutePlannerState {
this.visibility = RouteVisibility.private, this.visibility = RouteVisibility.private,
this.stops = const [], this.stops = const [],
this.straightLineDistanceMeters = 0.0, this.straightLineDistanceMeters = 0.0,
this.geometry = const [],
this.estimatedDistanceMeters,
this.estimatedDurationSeconds,
this.isCalculatingRoute = false,
this.routeCalculationError,
this.isSaving = false, this.isSaving = false,
this.errorMessage, this.errorMessage,
this.isDirty = false, this.isDirty = false,
@ -84,13 +94,38 @@ class RoutePlannerState {
double get distanceMiles => straightLineDistanceMeters * 0.000621371; double get distanceMiles => straightLineDistanceMeters * 0.000621371;
String get formattedDistance => double? get roadDistanceMiles =>
'${distanceMiles.toStringAsFixed(1)} mi (Straight-line)'; estimatedDistanceMeters != null ? estimatedDistanceMeters! * 0.000621371 : null;
String get formattedDistanceMiles => '${distanceMiles.toStringAsFixed(1)} mi'; String? get formattedRoadDistanceMiles =>
roadDistanceMiles != null ? '${roadDistanceMiles!.toStringAsFixed(1)} mi' : null;
String? get formattedRoadDistanceKm =>
estimatedDistanceMeters != null ? '${(estimatedDistanceMeters! / 1000).toStringAsFixed(1)} km' : null;
String? get formattedRoadDuration {
if (estimatedDurationSeconds == null) return null;
final mins = (estimatedDurationSeconds! / 60).round();
if (mins < 60) return '$mins min';
final hrs = mins ~/ 60;
final remMins = mins % 60;
return '${hrs}h ${remMins}m';
}
String get formattedDistance {
if (estimatedDistanceMeters != null) {
return '${roadDistanceMiles!.toStringAsFixed(1)} mi (Road Est.)';
}
return '${distanceMiles.toStringAsFixed(1)} mi (Straight-line)';
}
String get formattedDistanceMiles =>
roadDistanceMiles != null ? '${roadDistanceMiles!.toStringAsFixed(1)} mi' : '${distanceMiles.toStringAsFixed(1)} mi';
String get formattedDistanceKm => String get formattedDistanceKm =>
'${(straightLineDistanceMeters / 1000).toStringAsFixed(1)} km'; estimatedDistanceMeters != null
? '${(estimatedDistanceMeters! / 1000).toStringAsFixed(1)} km'
: '${(straightLineDistanceMeters / 1000).toStringAsFixed(1)} km';
List<AppLatLng> get previewPoints => stops.map((s) => s.latLng).toList(); List<AppLatLng> get previewPoints => stops.map((s) => s.latLng).toList();
@ -101,8 +136,17 @@ class RoutePlannerState {
RouteVisibility? visibility, RouteVisibility? visibility,
List<RouteStop>? stops, List<RouteStop>? stops,
double? straightLineDistanceMeters, double? straightLineDistanceMeters,
List<AppLatLng>? geometry,
double? estimatedDistanceMeters,
bool clearEstimatedDistance = false,
int? estimatedDurationSeconds,
bool clearEstimatedDuration = false,
bool? isCalculatingRoute,
String? routeCalculationError,
bool clearRouteCalculationError = false,
bool? isSaving, bool? isSaving,
String? errorMessage, String? errorMessage,
bool clearErrorMessage = false,
bool? isDirty, bool? isDirty,
DateTime? createdAt, DateTime? createdAt,
}) { }) {
@ -114,8 +158,19 @@ class RoutePlannerState {
stops: stops ?? this.stops, stops: stops ?? this.stops,
straightLineDistanceMeters: straightLineDistanceMeters:
straightLineDistanceMeters ?? this.straightLineDistanceMeters, straightLineDistanceMeters ?? this.straightLineDistanceMeters,
geometry: geometry ?? this.geometry,
estimatedDistanceMeters: clearEstimatedDistance
? null
: (estimatedDistanceMeters ?? this.estimatedDistanceMeters),
estimatedDurationSeconds: clearEstimatedDuration
? null
: (estimatedDurationSeconds ?? this.estimatedDurationSeconds),
isCalculatingRoute: isCalculatingRoute ?? this.isCalculatingRoute,
routeCalculationError: clearRouteCalculationError
? null
: (routeCalculationError ?? this.routeCalculationError),
isSaving: isSaving ?? this.isSaving, isSaving: isSaving ?? this.isSaving,
errorMessage: errorMessage, errorMessage: clearErrorMessage ? null : (errorMessage ?? this.errorMessage),
isDirty: isDirty ?? this.isDirty, isDirty: isDirty ?? this.isDirty,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
); );
@ -125,6 +180,7 @@ class RoutePlannerState {
/// Controller managing in-memory route editing state. /// Controller managing in-memory route editing state.
class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> { class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
final Ref ref; final Ref ref;
int _calculationSequence = 0;
RoutePlannerNotifier(this.ref, [MotorSignalRoute? initialRoute]) RoutePlannerNotifier(this.ref, [MotorSignalRoute? initialRoute])
: super( : super(
@ -140,9 +196,23 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
createdAt: initialRoute.createdAt, createdAt: initialRoute.createdAt,
) )
: const RoutePlannerState(), : const RoutePlannerState(),
); ) {
if (initialRoute != null && initialRoute.orderedStops.length >= 2) {
_calculateRoadRoute();
}
}
static bool isValidCoordinate(double? lat, double? lng) {
if (lat == null || lng == null) return false;
if (lat.isNaN || lng.isNaN || lat.isInfinite || lng.isInfinite) return false;
if (lat < -90.0 || lat > 90.0) return false;
if (lng < -180.0 || lng > 180.0) return false;
if (lat == 0.0 && lng == 0.0) return false;
return true;
}
void loadRoute(MotorSignalRoute route) { void loadRoute(MotorSignalRoute route) {
_calculationSequence++;
state = RoutePlannerState( state = RoutePlannerState(
routeId: route.id, routeId: route.id,
title: route.title, title: route.title,
@ -153,9 +223,13 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
createdAt: route.createdAt, createdAt: route.createdAt,
isDirty: false, isDirty: false,
); );
if (route.orderedStops.length >= 2) {
_calculateRoadRoute();
}
} }
void reset() { void reset() {
_calculationSequence++;
state = const RoutePlannerState(); state = const RoutePlannerState();
} }
@ -179,56 +253,124 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
RouteStopType? stopType, RouteStopType? stopType,
String? notes, String? notes,
}) { }) {
final effectiveLat = stop?.latitude ?? latitude;
final effectiveLng = stop?.longitude ?? longitude;
if (!isValidCoordinate(effectiveLat, effectiveLng)) {
state = state.copyWith(
errorMessage: 'Invalid coordinates: latitude [-90, 90], longitude [-180, 180], non-zero.',
);
return;
}
final effectiveType = stop?.stopType ?? stopType ?? (state.stops.isEmpty ? RouteStopType.origin : RouteStopType.waypoint);
final updatedStops = List<RouteStop>.from(state.stops); final updatedStops = List<RouteStop>.from(state.stops);
final effectiveOrder = updatedStops.length;
final newStop = stop != null final newStop = stop != null
? stop.copyWith(order: effectiveOrder, orderIndex: effectiveOrder) ? stop.copyWith(
latitude: effectiveLat!,
longitude: effectiveLng!,
type: effectiveType,
stopType: effectiveType,
)
: RouteStop( : RouteStop(
id: 'stop_${DateTime.now().millisecondsSinceEpoch}_$effectiveOrder', id: 'stop_${DateTime.now().millisecondsSinceEpoch}_${updatedStops.length}',
order: effectiveOrder, order: updatedStops.length,
orderIndex: effectiveOrder, orderIndex: updatedStops.length,
type: stopType ?? RouteStopType.waypoint, type: effectiveType,
stopType: stopType ?? RouteStopType.waypoint, stopType: effectiveType,
displayName: displayName ?? 'Stop $effectiveOrder', displayName: displayName ?? 'Stop ${updatedStops.length + 1}',
latitude: latitude ?? 0.0, latitude: effectiveLat!,
longitude: longitude ?? 0.0, longitude: effectiveLng!,
notes: notes ?? '', notes: notes ?? '',
); );
updatedStops.add(newStop); // Canonical ordering:
// 1. If origin/start, goes at index 0.
// 2. If destination, goes at end.
// 3. If waypoint/intermediate, and the list already ends with a destination,
// insert BEFORE the destination stop!
if (effectiveType == RouteStopType.origin || effectiveType == RouteStopType.start) {
updatedStops.insert(0, newStop);
} else if (effectiveType == RouteStopType.destination) {
updatedStops.add(newStop);
} else {
// Waypoint / intermediate
if (updatedStops.isNotEmpty &&
(updatedStops.last.stopType == RouteStopType.destination ||
updatedStops.last.type == RouteStopType.destination)) {
updatedStops.insert(updatedStops.length - 1, newStop);
} else {
updatedStops.add(newStop);
}
}
final distance = // Re-index all stops
MotorSignalRoute.calculateTotalDistanceMeters(updatedStops); final reindexed = <RouteStop>[];
for (int i = 0; i < updatedStops.length; i++) {
reindexed.add(updatedStops[i].copyWith(order: i, orderIndex: i));
}
final distance = MotorSignalRoute.calculateTotalDistanceMeters(reindexed);
state = state.copyWith( state = state.copyWith(
stops: updatedStops, stops: reindexed,
straightLineDistanceMeters: distance, straightLineDistanceMeters: distance,
geometry: const [], // Invalidate stale geometry immediately!
clearEstimatedDistance: true,
clearEstimatedDuration: true,
isCalculatingRoute: reindexed.length >= 2,
clearRouteCalculationError: true,
isDirty: true, isDirty: true,
errorMessage: null, clearErrorMessage: true,
); );
if (reindexed.length >= 2) {
_calculateRoadRoute();
}
} }
void updateStop(dynamic target, [RouteStop? maybeStop]) { void updateStop(dynamic target, [RouteStop? maybeStop]) {
final updatedStops = List<RouteStop>.from(state.stops); final updatedStops = List<RouteStop>.from(state.stops);
int targetIdx = -1;
RouteStop updated;
if (target is RouteStop) { if (target is RouteStop) {
final idx = updatedStops.indexWhere((s) => s.id == target.id); targetIdx = updatedStops.indexWhere((s) => s.id == target.id);
if (idx == -1) return; if (targetIdx == -1) return;
updatedStops[idx] = target.copyWith(order: idx, orderIndex: idx); updated = target;
} else if (target is int && maybeStop != null) { } else if (target is int && maybeStop != null) {
if (target < 0 || target >= updatedStops.length) return; if (target < 0 || target >= updatedStops.length) return;
updatedStops[target] = maybeStop.copyWith(order: target, orderIndex: target); targetIdx = target;
updated = maybeStop;
} else { } else {
return; return;
} }
final distance = if (!isValidCoordinate(updated.latitude, updated.longitude)) {
MotorSignalRoute.calculateTotalDistanceMeters(updatedStops); state = state.copyWith(
errorMessage: 'Invalid coordinates for updated stop.',
);
return;
}
updatedStops[targetIdx] = updated.copyWith(order: targetIdx, orderIndex: targetIdx);
final distance = MotorSignalRoute.calculateTotalDistanceMeters(updatedStops);
state = state.copyWith( state = state.copyWith(
stops: updatedStops, stops: updatedStops,
straightLineDistanceMeters: distance, straightLineDistanceMeters: distance,
geometry: const [], // Invalidate stale geometry
clearEstimatedDistance: true,
clearEstimatedDuration: true,
isCalculatingRoute: updatedStops.length >= 2,
clearRouteCalculationError: true,
isDirty: true, isDirty: true,
errorMessage: null, clearErrorMessage: true,
); );
if (updatedStops.length >= 2) {
_calculateRoadRoute();
}
} }
void removeStop(dynamic target) { void removeStop(dynamic target) {
@ -242,7 +384,6 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
return; return;
} }
// Re-index remaining stops
final reindexed = <RouteStop>[]; final reindexed = <RouteStop>[];
for (int i = 0; i < updatedStops.length; i++) { for (int i = 0; i < updatedStops.length; i++) {
reindexed.add(updatedStops[i].copyWith(order: i, orderIndex: i)); reindexed.add(updatedStops[i].copyWith(order: i, orderIndex: i));
@ -252,9 +393,18 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
state = state.copyWith( state = state.copyWith(
stops: reindexed, stops: reindexed,
straightLineDistanceMeters: distance, straightLineDistanceMeters: distance,
geometry: const [], // Invalidate stale geometry
clearEstimatedDistance: true,
clearEstimatedDuration: true,
isCalculatingRoute: reindexed.length >= 2,
clearRouteCalculationError: true,
isDirty: true, isDirty: true,
errorMessage: null, clearErrorMessage: true,
); );
if (reindexed.length >= 2) {
_calculateRoadRoute();
}
} }
void reorderStops(int oldIndex, int newIndex) { void reorderStops(int oldIndex, int newIndex) {
@ -275,9 +425,18 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
state = state.copyWith( state = state.copyWith(
stops: reindexed, stops: reindexed,
straightLineDistanceMeters: distance, straightLineDistanceMeters: distance,
geometry: const [], // Invalidate stale geometry
clearEstimatedDistance: true,
clearEstimatedDuration: true,
isCalculatingRoute: reindexed.length >= 2,
clearRouteCalculationError: true,
isDirty: true, isDirty: true,
errorMessage: null, clearErrorMessage: true,
); );
if (reindexed.length >= 2) {
_calculateRoadRoute();
}
} }
void reverseStops() { void reverseStops() {
@ -292,9 +451,65 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
state = state.copyWith( state = state.copyWith(
stops: reindexed, stops: reindexed,
straightLineDistanceMeters: distance, straightLineDistanceMeters: distance,
geometry: const [], // Invalidate stale geometry
clearEstimatedDistance: true,
clearEstimatedDuration: true,
isCalculatingRoute: reindexed.length >= 2,
clearRouteCalculationError: true,
isDirty: true, isDirty: true,
errorMessage: null, clearErrorMessage: true,
); );
if (reindexed.length >= 2) {
_calculateRoadRoute();
}
}
Future<void> recalculateRoute() => _calculateRoadRoute();
Future<void> _calculateRoadRoute() async {
if (state.stops.length < 2) {
state = state.copyWith(
geometry: const [],
clearEstimatedDistance: true,
clearEstimatedDuration: true,
isCalculatingRoute: false,
clearRouteCalculationError: true,
);
return;
}
final seq = ++_calculationSequence;
state = state.copyWith(
isCalculatingRoute: true,
clearRouteCalculationError: true,
geometry: const [],
);
try {
final provider = ref.read(activeRouteCalculationProvider);
final result = await provider.calculateRoute(state.stops);
if (seq != _calculationSequence) return;
state = state.copyWith(
geometry: result.geometry,
estimatedDistanceMeters: result.estimatedDistanceMeters,
estimatedDurationSeconds: result.estimatedDurationSeconds,
isCalculatingRoute: false,
clearRouteCalculationError: true,
);
} catch (e) {
if (seq != _calculationSequence) return;
final err = e.toString()
.replaceAll('RouteProviderException: ', '')
.replaceAll('Exception: ', '');
state = state.copyWith(
geometry: const [], // Stale geometry cleared, no straight-line fallback
clearEstimatedDistance: true,
clearEstimatedDuration: true,
isCalculatingRoute: false,
routeCalculationError: err,
);
}
} }
Future<MotorSignalRoute> saveRoute({ Future<MotorSignalRoute> saveRoute({

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