feat: add real route planning and group ride navigation experience
This commit is contained in:
parent
9958a8a600
commit
92ce23cdb7
|
|
@ -8,8 +8,13 @@ import '../../services/group_rides/group_ride_models.dart';
|
|||
import '../../services/group_rides/group_ride_providers.dart';
|
||||
import '../../services/social/social_providers.dart';
|
||||
import '../../services/auth/auth_providers.dart';
|
||||
import '../../services/map/map_provider_interface.dart';
|
||||
import '../../services/map/unified_map_view.dart';
|
||||
import '../../services/routes/route_models.dart';
|
||||
import '../../services/routes/route_provider.dart';
|
||||
import '../../services/routes/route_providers.dart';
|
||||
import '../../services/routes/route_service.dart';
|
||||
import '../routes/widgets/address_autocomplete_field.dart';
|
||||
|
||||
class CreateGroupRideScreen extends ConsumerStatefulWidget {
|
||||
const CreateGroupRideScreen({super.key});
|
||||
|
|
@ -29,21 +34,17 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
TimeOfDay _meetupTime = const TimeOfDay(hour: 9, minute: 0);
|
||||
TimeOfDay _departureTime = const TimeOfDay(hour: 9, minute: 30);
|
||||
|
||||
// Meeting point
|
||||
// Meeting point (Start)
|
||||
ResolvedPlace? _meetupPlace;
|
||||
final _meetupNameController = TextEditingController();
|
||||
final _meetupAddressController = TextEditingController();
|
||||
final _meetupNotesController = TextEditingController();
|
||||
double _meetupLat = 34.5378;
|
||||
double _meetupLng = -83.9849;
|
||||
|
||||
// Destination
|
||||
bool _hasDestination = true;
|
||||
ResolvedPlace? _destinationPlace;
|
||||
final _destNameController = TextEditingController();
|
||||
final _destAddressController = TextEditingController();
|
||||
double _destLat = 34.8826;
|
||||
double _destLng = -83.9281;
|
||||
|
||||
// Attached Route info
|
||||
// Attached Route info (Provenance)
|
||||
String? _selectedRouteId;
|
||||
String? _selectedRouteTitle;
|
||||
double? _selectedRouteDistanceMeters;
|
||||
|
|
@ -51,6 +52,21 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
|
||||
// Intermediate stops
|
||||
final List<GroupRideLocation> _stops = [];
|
||||
static const int _maxTechnicalStops = RouteService.maxTechnicalStops; // 25
|
||||
|
||||
int get _totalPlannedStops =>
|
||||
1 + (_hasDestination && _destinationPlace != null ? 1 : 0) + _stops.length;
|
||||
|
||||
bool get _canAddStop =>
|
||||
1 + (_hasDestination ? 1 : 0) + _stops.length < _maxTechnicalStops;
|
||||
|
||||
// Runtime Route Calculation & Preview
|
||||
RouteCalculationResult? _calculatedRouteResult;
|
||||
bool _isCalculatingRoute = false;
|
||||
String? _routeCalculationError;
|
||||
int _calculationSequence = 0;
|
||||
AppLatLng? _mapCenter;
|
||||
final double _mapZoom = 11.0;
|
||||
|
||||
// Privacy & policies
|
||||
GroupRideVisibility _visibility = GroupRideVisibility.friendsOnly;
|
||||
|
|
@ -65,10 +81,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
_titleController.dispose();
|
||||
_descriptionController.dispose();
|
||||
_meetupNameController.dispose();
|
||||
_meetupAddressController.dispose();
|
||||
_meetupNotesController.dispose();
|
||||
_destNameController.dispose();
|
||||
_destAddressController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -76,10 +90,103 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
return DateTime(date.year, date.month, date.day, time.hour, time.minute);
|
||||
}
|
||||
|
||||
void _triggerRouteCalculation() {
|
||||
if (_meetupPlace == null || !_hasDestination || _destinationPlace == null) {
|
||||
setState(() {
|
||||
_calculatedRouteResult = null;
|
||||
_routeCalculationError = null;
|
||||
_isCalculatingRoute = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final seq = ++_calculationSequence;
|
||||
setState(() {
|
||||
_isCalculatingRoute = true;
|
||||
_routeCalculationError = null;
|
||||
});
|
||||
|
||||
final orderedStops = <RouteStop>[
|
||||
RouteStop(
|
||||
id: 'origin_meetup',
|
||||
order: 0,
|
||||
orderIndex: 0,
|
||||
type: RouteStopType.origin,
|
||||
stopType: RouteStopType.origin,
|
||||
displayName: _meetupPlace!.displayName,
|
||||
latitude: _meetupPlace!.latitude,
|
||||
longitude: _meetupPlace!.longitude,
|
||||
notes: _meetupNotesController.text.trim(),
|
||||
),
|
||||
];
|
||||
|
||||
for (int i = 0; i < _stops.length; i++) {
|
||||
final s = _stops[i];
|
||||
orderedStops.add(RouteStop(
|
||||
id: 'stop_$i',
|
||||
order: i + 1,
|
||||
orderIndex: i + 1,
|
||||
type: RouteStopType.waypoint,
|
||||
stopType: RouteStopType.waypoint,
|
||||
displayName: s.displayName,
|
||||
latitude: s.latitude,
|
||||
longitude: s.longitude,
|
||||
notes: s.notes,
|
||||
));
|
||||
}
|
||||
|
||||
orderedStops.add(RouteStop(
|
||||
id: 'dest',
|
||||
order: orderedStops.length,
|
||||
orderIndex: orderedStops.length,
|
||||
type: RouteStopType.destination,
|
||||
stopType: RouteStopType.destination,
|
||||
displayName: _destinationPlace!.displayName,
|
||||
latitude: _destinationPlace!.latitude,
|
||||
longitude: _destinationPlace!.longitude,
|
||||
));
|
||||
|
||||
ref
|
||||
.read(activeRouteCalculationProvider)
|
||||
.calculateRoute(orderedStops)
|
||||
.then((result) {
|
||||
if (!mounted || seq != _calculationSequence) return;
|
||||
setState(() {
|
||||
_calculatedRouteResult = result;
|
||||
_isCalculatingRoute = false;
|
||||
_routeCalculationError = null;
|
||||
if (result.geometry.isNotEmpty) {
|
||||
_mapCenter = result.geometry.first;
|
||||
}
|
||||
});
|
||||
}).catchError((e) {
|
||||
if (!mounted || seq != _calculationSequence) return;
|
||||
setState(() {
|
||||
_isCalculatingRoute = false;
|
||||
_calculatedRouteResult = null;
|
||||
_routeCalculationError = e.toString()
|
||||
.replaceAll('RouteProviderException: ', '')
|
||||
.replaceAll('Exception: ', '');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
setState(() => _errorMessage = null);
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
if (_meetupPlace == null) {
|
||||
setState(() => _errorMessage =
|
||||
'Please search and select a valid Meetup Location from suggestions.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_hasDestination && _destinationPlace == null) {
|
||||
setState(() => _errorMessage =
|
||||
'Please search and select a valid Destination from suggestions.');
|
||||
return;
|
||||
}
|
||||
|
||||
final startAt = _buildFullDateTime(_scheduledDate, _meetupTime);
|
||||
final depAt = _buildFullDateTime(_scheduledDate, _departureTime);
|
||||
|
||||
|
|
@ -89,7 +196,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
return;
|
||||
}
|
||||
|
||||
final organizerProfile = ref.read(myRiderProfileProvider).value;
|
||||
final organizerProfile = ref.read(myRiderProfileProvider).valueOrNull ??
|
||||
(await ref.read(myRiderProfileProvider.future));
|
||||
if (organizerProfile == null) {
|
||||
setState(() => _errorMessage =
|
||||
'Rider profile required. Please complete your profile first.');
|
||||
|
|
@ -99,21 +207,24 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
setState(() => _isSaving = true);
|
||||
try {
|
||||
final meetingPoint = GroupRideLocation(
|
||||
latitude: _meetupLat,
|
||||
longitude: _meetupLng,
|
||||
displayName: _meetupNameController.text.trim(),
|
||||
address: _meetupAddressController.text.trim(),
|
||||
latitude: _meetupPlace!.latitude,
|
||||
longitude: _meetupPlace!.longitude,
|
||||
displayName: _meetupNameController.text.trim().isNotEmpty
|
||||
? _meetupNameController.text.trim()
|
||||
: _meetupPlace!.displayName,
|
||||
address: _meetupPlace!.formattedAddress ?? '',
|
||||
locationType: GroupRideLocationType.meetup,
|
||||
notes: _meetupNotesController.text.trim(),
|
||||
);
|
||||
|
||||
final destination = _hasDestination &&
|
||||
_destNameController.text.trim().isNotEmpty
|
||||
final destination = _hasDestination && _destinationPlace != null
|
||||
? GroupRideLocation(
|
||||
latitude: _destLat,
|
||||
longitude: _destLng,
|
||||
displayName: _destNameController.text.trim(),
|
||||
address: _destAddressController.text.trim(),
|
||||
latitude: _destinationPlace!.latitude,
|
||||
longitude: _destinationPlace!.longitude,
|
||||
displayName: _destNameController.text.trim().isNotEmpty
|
||||
? _destNameController.text.trim()
|
||||
: _destinationPlace!.displayName,
|
||||
address: _destinationPlace!.formattedAddress ?? '',
|
||||
locationType: GroupRideLocationType.destination,
|
||||
)
|
||||
: null;
|
||||
|
|
@ -168,9 +279,14 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
|
||||
if (route.stops.isNotEmpty) {
|
||||
final origin = route.stops.first;
|
||||
_meetupPlace = ResolvedPlace(
|
||||
providerId: origin.id,
|
||||
displayName: origin.displayName,
|
||||
formattedAddress: origin.notes.isNotEmpty ? origin.notes : origin.displayName,
|
||||
latitude: origin.latitude,
|
||||
longitude: origin.longitude,
|
||||
);
|
||||
_meetupNameController.text = origin.displayName;
|
||||
_meetupLat = origin.latitude;
|
||||
_meetupLng = origin.longitude;
|
||||
if (origin.notes.isNotEmpty) {
|
||||
_meetupNotesController.text = origin.notes;
|
||||
}
|
||||
|
|
@ -178,12 +294,14 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
if (route.stops.length > 1) {
|
||||
final dest = route.stops.last;
|
||||
_hasDestination = true;
|
||||
_destinationPlace = ResolvedPlace(
|
||||
providerId: dest.id,
|
||||
displayName: dest.displayName,
|
||||
formattedAddress: dest.notes.isNotEmpty ? dest.notes : dest.displayName,
|
||||
latitude: dest.latitude,
|
||||
longitude: dest.longitude,
|
||||
);
|
||||
_destNameController.text = dest.displayName;
|
||||
_destLat = dest.latitude;
|
||||
_destLng = dest.longitude;
|
||||
if (dest.notes.isNotEmpty) {
|
||||
_destAddressController.text = dest.notes;
|
||||
}
|
||||
|
||||
_stops.clear();
|
||||
if (route.stops.length > 2) {
|
||||
|
|
@ -203,6 +321,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
}
|
||||
}
|
||||
});
|
||||
_triggerRouteCalculation();
|
||||
}
|
||||
|
||||
void _showSelectRouteSheet(MotorSignalThemeColors colors) {
|
||||
|
|
@ -227,7 +346,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'IMPORT SAVED ROUTE',
|
||||
'USE EXISTING ROUTE',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
|
@ -307,10 +426,31 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
void _addStopDialog(MotorSignalThemeColors colors) {
|
||||
final nameCtrl = TextEditingController();
|
||||
final notesCtrl = TextEditingController();
|
||||
var stopType = GroupRideLocationType.fuel;
|
||||
void _addStopDialog(MotorSignalThemeColors colors, [int? editIndex]) {
|
||||
if (editIndex == null && !_canAddStop) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Maximum of 25 total stops reached.'),
|
||||
backgroundColor: colors.surfaceContainerHighest,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final existing = editIndex != null ? _stops[editIndex] : null;
|
||||
ResolvedPlace? resolved = existing != null
|
||||
? ResolvedPlace(
|
||||
providerId: 'stop_$editIndex',
|
||||
displayName: existing.displayName,
|
||||
formattedAddress: existing.address,
|
||||
latitude: existing.latitude,
|
||||
longitude: existing.longitude,
|
||||
)
|
||||
: null;
|
||||
|
||||
final nameCtrl = TextEditingController(text: existing?.displayName ?? '');
|
||||
final notesCtrl = TextEditingController(text: existing?.notes ?? '');
|
||||
var stopType = existing?.locationType ?? GroupRideLocationType.fuel;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
|
|
@ -321,17 +461,37 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: colors.outlineVariant),
|
||||
),
|
||||
title: const Text('Add Intermediate Stop',
|
||||
style: TextStyle(color: Colors.white, fontSize: 16)),
|
||||
content: Column(
|
||||
title: Text(
|
||||
existing != null ? 'Edit Intermediate Stop' : 'Add Intermediate Stop',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
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: 'Stop Name (e.g. Shell Fuel / Overlook)',
|
||||
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(
|
||||
|
|
@ -339,7 +499,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<GroupRideLocationType>(
|
||||
initialValue: stopType,
|
||||
dropdownColor: colors.surfaceContainerHigh,
|
||||
|
|
@ -368,13 +528,15 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
if (val != null) setDialogState(() => stopType = val);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
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(
|
||||
|
|
@ -384,11 +546,11 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('CANCEL',
|
||||
style: TextStyle(color: Colors.white60)),
|
||||
child: const Text('CANCEL', style: TextStyle(color: Colors.white60)),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
|
|
@ -396,20 +558,35 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
foregroundColor: Colors.black,
|
||||
),
|
||||
onPressed: () {
|
||||
if (nameCtrl.text.trim().isNotEmpty) {
|
||||
setState(() {
|
||||
_stops.add(GroupRideLocation(
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
displayName: nameCtrl.text.trim(),
|
||||
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: const Text('ADD STOP'),
|
||||
child: Text(existing != null ? 'SAVE STOP' : 'ADD STOP'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -420,6 +597,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.motorColors;
|
||||
ref.watch(myRiderProfileProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surfaceContainer,
|
||||
|
|
@ -435,11 +613,6 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.alt_route, color: colors.cyberCyan),
|
||||
tooltip: 'Import Route',
|
||||
onPressed: () => _showSelectRouteSheet(colors),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _isSaving ? null : _submit,
|
||||
child: _isSaving
|
||||
|
|
@ -461,7 +634,9 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
body: ResponsiveLayout(
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: ResponsiveLayout(
|
||||
builder: (context, snapshot) {
|
||||
final isWide = snapshot.sizeClass == WindowSizeClass.expanded ||
|
||||
(snapshot.isLandscape && snapshot.width >= 720);
|
||||
|
|
@ -472,6 +647,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
return _buildCompactForm(context, colors, snapshot);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -480,9 +656,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
MotorSignalThemeColors colors,
|
||||
ResponsiveSnapshot snapshot,
|
||||
) {
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
|
|
@ -506,7 +680,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
),
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
// Right Column: Meeting Point, Destination & Route
|
||||
// Right Column: Meeting Point, Destination & Stops, Preview, Submit
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Column(
|
||||
|
|
@ -516,6 +690,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
const SizedBox(height: 24),
|
||||
_buildDestinationAndStopsSection(colors),
|
||||
const SizedBox(height: 24),
|
||||
_buildMapPreviewSection(colors, snapshot),
|
||||
const SizedBox(height: 24),
|
||||
_buildSubmitButton(colors),
|
||||
],
|
||||
),
|
||||
|
|
@ -525,7 +701,6 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -534,9 +709,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
MotorSignalThemeColors colors,
|
||||
ResponsiveSnapshot snapshot,
|
||||
) {
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
if (_errorMessage != null) _buildErrorBanner(colors),
|
||||
|
|
@ -548,12 +721,13 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
const SizedBox(height: 24),
|
||||
_buildDestinationAndStopsSection(colors),
|
||||
const SizedBox(height: 24),
|
||||
_buildMapPreviewSection(colors, snapshot),
|
||||
const SizedBox(height: 24),
|
||||
_buildPrivacySection(colors),
|
||||
const SizedBox(height: 32),
|
||||
_buildSubmitButton(colors),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -732,6 +906,22 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
children: [
|
||||
_buildSectionHeader('3. MEETING LOCATION', colors),
|
||||
const SizedBox(height: 10),
|
||||
AddressAutocompleteField(
|
||||
labelText: 'Meeting Place Address / Location *',
|
||||
hintText: 'Search meetup address or landmark...',
|
||||
initialPlace: _meetupPlace,
|
||||
isRequired: true,
|
||||
onPlaceResolved: (place) {
|
||||
setState(() {
|
||||
_meetupPlace = place;
|
||||
if (place != null && _meetupNameController.text.trim().isEmpty) {
|
||||
_meetupNameController.text = place.displayName;
|
||||
}
|
||||
});
|
||||
_triggerRouteCalculation();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _meetupNameController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
|
|
@ -748,16 +938,6 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _meetupAddressController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: _inputDecoration(
|
||||
'Address or Landmark',
|
||||
'e.g. 123 Main St, Dahlonega, GA',
|
||||
colors,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _meetupNotesController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
|
|
@ -772,6 +952,9 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
}
|
||||
|
||||
Widget _buildDestinationAndStopsSection(MotorSignalThemeColors colors) {
|
||||
final canAdd = _canAddStop;
|
||||
final totalStops = _totalPlannedStops;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
|
@ -783,10 +966,31 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
title: const Text('Specific Destination',
|
||||
style: TextStyle(color: Colors.white)),
|
||||
value: _hasDestination,
|
||||
onChanged: (val) => setState(() => _hasDestination = val),
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_hasDestination = val;
|
||||
});
|
||||
_triggerRouteCalculation();
|
||||
},
|
||||
),
|
||||
if (_hasDestination) ...[
|
||||
const SizedBox(height: 8),
|
||||
AddressAutocompleteField(
|
||||
labelText: 'Destination Address / Location *',
|
||||
hintText: 'Search destination address or scenic peak...',
|
||||
initialPlace: _destinationPlace,
|
||||
isRequired: _hasDestination,
|
||||
onPlaceResolved: (place) {
|
||||
setState(() {
|
||||
_destinationPlace = place;
|
||||
if (place != null && _destNameController.text.trim().isEmpty) {
|
||||
_destNameController.text = place.displayName;
|
||||
}
|
||||
});
|
||||
_triggerRouteCalculation();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _destNameController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
|
|
@ -796,19 +1000,9 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
colors,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _destAddressController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: _inputDecoration(
|
||||
'Destination Address (optional)',
|
||||
'e.g. Hiawassee, GA',
|
||||
colors,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
// Route Pre-fill / Attachment
|
||||
// USE EXISTING ROUTE Card
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(12),
|
||||
|
|
@ -828,7 +1022,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
Text(
|
||||
_selectedRouteTitle != null
|
||||
? 'Route: $_selectedRouteTitle'
|
||||
: 'Plan with Saved Route',
|
||||
: 'Use Existing Stage 8 Route',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
|
@ -839,7 +1033,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
Text(
|
||||
_selectedRouteTitle != null
|
||||
? '$_selectedRouteStopCount stops • ${(_selectedRouteDistanceMeters != null ? (_selectedRouteDistanceMeters! / 1609.34).toStringAsFixed(1) : 0)} mi'
|
||||
: 'Optional: import stops & coordinates',
|
||||
: 'Import saved route stops & coordinates',
|
||||
style:
|
||||
const TextStyle(color: Colors.white54, fontSize: 11),
|
||||
),
|
||||
|
|
@ -861,19 +1055,19 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
},
|
||||
)
|
||||
else
|
||||
OutlinedButton(
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _showSelectRouteSheet(colors),
|
||||
style: OutlinedButton.styleFrom(
|
||||
icon: const Icon(Icons.alt_route, size: 14),
|
||||
label: const Text('USE EXISTING ROUTE'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colors.cyberCyan.withValues(alpha: 0.15),
|
||||
foregroundColor: colors.cyberCyan,
|
||||
side: BorderSide(color: colors.cyberCyan),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: Text(
|
||||
'IMPORT',
|
||||
style: TextStyle(
|
||||
color: colors.cyberCyan,
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
|
|
@ -885,22 +1079,29 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Flexible(
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Intermediate Stops',
|
||||
'Intermediate Stops ($totalStops/$_maxTechnicalStops max)',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () => _addStopDialog(colors),
|
||||
onPressed: canAdd ? () => _addStopDialog(colors) : null,
|
||||
icon: Icon(Icons.add_location_alt,
|
||||
size: 16, color: colors.cyberCyan),
|
||||
label: Text('ADD STOP',
|
||||
style: TextStyle(color: colors.cyberCyan)),
|
||||
size: 16,
|
||||
color: canAdd ? colors.cyberCyan : Colors.white24),
|
||||
label: Text(
|
||||
canAdd ? 'ADD STOP' : 'LIMIT REACHED',
|
||||
style: TextStyle(
|
||||
color: canAdd ? colors.cyberCyan : Colors.white24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -921,12 +1122,14 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
final item = _stops.removeAt(oldIndex);
|
||||
_stops.insert(newIndex, item);
|
||||
});
|
||||
_triggerRouteCalculation();
|
||||
},
|
||||
children: [
|
||||
for (int i = 0; i < _stops.length; i++)
|
||||
ListTile(
|
||||
key: ValueKey('stop_$i'),
|
||||
tileColor: colors.surfaceContainerHigh,
|
||||
onTap: () => _addStopDialog(colors, i),
|
||||
leading: CircleAvatar(
|
||||
radius: 12,
|
||||
backgroundColor: colors.cyberCyan.withValues(alpha: 0.2),
|
||||
|
|
@ -945,14 +1148,19 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
const TextStyle(color: Colors.white, fontSize: 13),
|
||||
),
|
||||
subtitle: Text(
|
||||
_stops[i].locationType.label,
|
||||
'${_stops[i].locationType.label}${_stops[i].address.isNotEmpty ? ' • ${_stops[i].address}' : ''}',
|
||||
style:
|
||||
const TextStyle(color: Colors.white54, fontSize: 11),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline,
|
||||
size: 18, color: Colors.white38),
|
||||
onPressed: () => setState(() => _stops.removeAt(i)),
|
||||
onPressed: () {
|
||||
setState(() => _stops.removeAt(i));
|
||||
_triggerRouteCalculation();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -961,6 +1169,242 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildMapPreviewSection(
|
||||
MotorSignalThemeColors colors,
|
||||
ResponsiveSnapshot snapshot,
|
||||
) {
|
||||
final markers = <AppMapMarker>[];
|
||||
if (_meetupPlace != null) {
|
||||
markers.add(
|
||||
AppMapMarker(
|
||||
id: 'preview_meetup',
|
||||
position: _meetupPlace!.latLng,
|
||||
label: _meetupPlace!.displayName,
|
||||
iconWidget: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.launchGreen,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: const [
|
||||
BoxShadow(color: Colors.black45, blurRadius: 4),
|
||||
],
|
||||
),
|
||||
child: const Icon(Icons.play_arrow, color: Colors.black, size: 14),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (int i = 0; i < _stops.length; i++) {
|
||||
final s = _stops[i];
|
||||
markers.add(
|
||||
AppMapMarker(
|
||||
id: 'preview_stop_$i',
|
||||
position: AppLatLng(s.latitude, s.longitude),
|
||||
label: s.displayName,
|
||||
iconWidget: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.cyberCyan,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: const [
|
||||
BoxShadow(color: Colors.black45, blurRadius: 4),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
'${i + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_hasDestination && _destinationPlace != null) {
|
||||
markers.add(
|
||||
AppMapMarker(
|
||||
id: 'preview_dest',
|
||||
position: _destinationPlace!.latLng,
|
||||
label: _destinationPlace!.displayName,
|
||||
iconWidget: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.raceRed,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: const [
|
||||
BoxShadow(color: Colors.black45, blurRadius: 4),
|
||||
],
|
||||
),
|
||||
child: const Icon(Icons.flag, color: Colors.white, size: 14),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final polylines = <AppPolyline>[];
|
||||
if (_calculatedRouteResult != null &&
|
||||
_calculatedRouteResult!.geometry.isNotEmpty) {
|
||||
polylines.add(
|
||||
AppPolyline(
|
||||
id: 'preview_route_line',
|
||||
points: _calculatedRouteResult!.geometry,
|
||||
color: colors.cyberCyan,
|
||||
width: 3.5,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final center = _mapCenter ??
|
||||
_meetupPlace?.latLng ??
|
||||
_destinationPlace?.latLng ??
|
||||
const AppLatLng(34.5378, -83.9849);
|
||||
|
||||
final mapHeight = snapshot.isLandscape ? 170.0 : 210.0;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildSectionHeader('ROUTE PREVIEW', colors),
|
||||
if (_calculatedRouteResult != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.cyberCyan.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
_calculatedRouteResult!.distanceLabel,
|
||||
style: TextStyle(
|
||||
color: colors.cyberCyan,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
height: mapHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: colors.outlineVariant),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: UnifiedMapView(
|
||||
center: center,
|
||||
zoom: _mapZoom,
|
||||
markers: markers,
|
||||
polylines: polylines,
|
||||
),
|
||||
),
|
||||
if (_isCalculatingRoute)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
LinearProgressIndicator(
|
||||
color: colors.cyberCyan,
|
||||
backgroundColor: Colors.black26,
|
||||
minHeight: 3,
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 4),
|
||||
color: Colors.black87,
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Calculating motorcycle route...',
|
||||
style: TextStyle(
|
||||
color: Colors.white70, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_routeCalculationError != null)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 6),
|
||||
color:
|
||||
colors.surfaceContainerHigh.withValues(alpha: 0.95),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded,
|
||||
color: colors.warningAmber, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_routeCalculationError!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70, fontSize: 11),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _triggerRouteCalculation,
|
||||
style: TextButton.styleFrom(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8),
|
||||
minimumSize: Size.zero,
|
||||
),
|
||||
child: Text('RETRY',
|
||||
style: TextStyle(
|
||||
color: colors.cyberCyan, fontSize: 11)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_calculatedRouteResult != null)
|
||||
Positioned(
|
||||
bottom: 4,
|
||||
right: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'Powered by Valhalla • Stadia Maps',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPrivacySection(MotorSignalThemeColors colors) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
|
|||
|
|
@ -476,14 +476,35 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
|||
],
|
||||
),
|
||||
actions: [
|
||||
if (isOrganizer)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.stop_circle_outlined,
|
||||
if (isOrganizer && ride.status == GroupRideStatus.underway)
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert, color: Colors.white70),
|
||||
tooltip: 'Ride Options',
|
||||
color: colors.surfaceContainerHigh,
|
||||
onSelected: (val) {
|
||||
if (val == 'end_ride') {
|
||||
_confirmEndRide(context, ride, currentUid, colors);
|
||||
}
|
||||
},
|
||||
itemBuilder: (ctx) => [
|
||||
PopupMenuItem(
|
||||
value: 'end_ride',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.stop_circle_outlined,
|
||||
color: colors.raceRed, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'End Ride',
|
||||
style: TextStyle(
|
||||
color: colors.raceRed,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
tooltip: 'End Ride',
|
||||
onPressed: () => _confirmEndRide(context, ride, currentUid, colors),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.people_outline, color: colors.cyberCyan),
|
||||
|
|
@ -1441,11 +1462,11 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
|||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: colors.surfaceContainerHigh,
|
||||
title: const Text(
|
||||
'End Group Ride?',
|
||||
'End this group ride?',
|
||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
content: const Text(
|
||||
'This will conclude the group ride for all participants and terminate live location sharing.',
|
||||
'Ending the ride will stop live location sharing and mark the Group Ride as completed.',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
actions: [
|
||||
|
|
@ -1469,9 +1490,6 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
|||
groupRideId: ride.id,
|
||||
newStatus: GroupRideStatus.completed,
|
||||
);
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
child: const Text('END RIDE'),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import '../../services/routes/navigation_handoff.dart';
|
|||
import '../../services/routes/route_models.dart';
|
||||
import '../../services/routes/route_providers.dart';
|
||||
import '../../services/routes/route_service.dart';
|
||||
import 'widgets/address_autocomplete_field.dart';
|
||||
|
||||
class RoutePlannerScreen extends ConsumerStatefulWidget {
|
||||
final String? initialRouteId;
|
||||
|
|
@ -162,6 +163,34 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: colors.cyberCyan),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 8),
|
||||
minimumSize: const Size(double.infinity, 36),
|
||||
),
|
||||
icon:
|
||||
Icon(Icons.search, size: 16, color: colors.cyberCyan),
|
||||
label: Text(
|
||||
'SEARCH ADDRESS / POI',
|
||||
style: TextStyle(
|
||||
color: colors.cyberCyan,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
onPressed: () => _openAddressSearchForStop(
|
||||
context,
|
||||
colors,
|
||||
setDialogState,
|
||||
nameCtrl,
|
||||
latCtrl,
|
||||
lngCtrl,
|
||||
notesCtrl,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
|
|
@ -170,6 +199,20 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
labelStyle: const TextStyle(color: Colors.white60),
|
||||
hintText: 'e.g. Skyline Summit, Fuel Stop',
|
||||
hintStyle: const TextStyle(color: Colors.white24),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(Icons.search,
|
||||
color: colors.cyberCyan, size: 20),
|
||||
tooltip: 'Search address',
|
||||
onPressed: () => _openAddressSearchForStop(
|
||||
context,
|
||||
colors,
|
||||
setDialogState,
|
||||
nameCtrl,
|
||||
latCtrl,
|
||||
lngCtrl,
|
||||
notesCtrl,
|
||||
),
|
||||
),
|
||||
enabledBorder: UnderlineInputBorder(
|
||||
borderSide: BorderSide(color: colors.outlineVariant),
|
||||
),
|
||||
|
|
@ -353,6 +396,66 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
void _openAddressSearchForStop(
|
||||
BuildContext context,
|
||||
MotorSignalThemeColors colors,
|
||||
void Function(void Function()) setDialogState,
|
||||
TextEditingController nameCtrl,
|
||||
TextEditingController latCtrl,
|
||||
TextEditingController lngCtrl,
|
||||
TextEditingController notesCtrl,
|
||||
) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: colors.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (sheetCtx) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: MediaQuery.of(sheetCtx).viewInsets.bottom + 16,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'SEARCH ADDRESS OR PLACE',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AddressAutocompleteField(
|
||||
labelText: 'Search location',
|
||||
autofocus: true,
|
||||
onPlaceResolved: (place) {
|
||||
if (place != null) {
|
||||
setDialogState(() {
|
||||
nameCtrl.text = place.displayName;
|
||||
latCtrl.text = place.latitude.toStringAsFixed(6);
|
||||
lngCtrl.text = place.longitude.toStringAsFixed(6);
|
||||
if (notesCtrl.text.isEmpty &&
|
||||
place.formattedAddress != null) {
|
||||
notesCtrl.text = place.formattedAddress!;
|
||||
}
|
||||
});
|
||||
Navigator.pop(sheetCtx);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _saveRoute() async {
|
||||
final title = _titleController.text.trim();
|
||||
if (title.length < 3 || title.length > 80) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,495 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/theme/theme_extensions.dart';
|
||||
import '../../../services/map/map_provider_interface.dart';
|
||||
import '../../../services/routes/route_provider.dart';
|
||||
import '../../../services/routes/route_providers.dart';
|
||||
|
||||
/// Reusable address and place autocomplete field for MotorSignal.
|
||||
///
|
||||
/// Integrates with [RouteProvider] (Stadia Maps / Pelias) to provide:
|
||||
/// - 350ms debounced search
|
||||
/// - Sequence-guarded asynchronous responses to prevent stale data
|
||||
/// - Immediate resolution when coordinates are present in search results
|
||||
/// - Invalidation of previous resolution upon text modification
|
||||
/// - Accessible suggestions list with loading, no-results, and error states
|
||||
class AddressAutocompleteField extends ConsumerStatefulWidget {
|
||||
final String labelText;
|
||||
final String? hintText;
|
||||
final ResolvedPlace? initialPlace;
|
||||
final String? initialQuery;
|
||||
final ValueChanged<ResolvedPlace?> onPlaceResolved;
|
||||
final AppLatLng? nearLocation;
|
||||
final String? Function(String?)? validator;
|
||||
final bool enabled;
|
||||
final bool autofocus;
|
||||
final FocusNode? focusNode;
|
||||
final TextEditingController? controller;
|
||||
final Widget? prefixIcon;
|
||||
final bool showAttribution;
|
||||
final VoidCallback? onClear;
|
||||
final bool isRequired;
|
||||
|
||||
const AddressAutocompleteField({
|
||||
super.key,
|
||||
required this.labelText,
|
||||
this.hintText,
|
||||
this.initialPlace,
|
||||
this.initialQuery,
|
||||
required this.onPlaceResolved,
|
||||
this.nearLocation,
|
||||
this.validator,
|
||||
this.enabled = true,
|
||||
this.autofocus = false,
|
||||
this.focusNode,
|
||||
this.controller,
|
||||
this.prefixIcon,
|
||||
this.showAttribution = true,
|
||||
this.onClear,
|
||||
this.isRequired = false,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<AddressAutocompleteField> createState() =>
|
||||
_AddressAutocompleteFieldState();
|
||||
}
|
||||
|
||||
class _AddressAutocompleteFieldState
|
||||
extends ConsumerState<AddressAutocompleteField> {
|
||||
late final TextEditingController _controller;
|
||||
late final FocusNode _focusNode;
|
||||
bool _internalController = false;
|
||||
bool _internalFocusNode = false;
|
||||
|
||||
ResolvedPlace? _resolvedPlace;
|
||||
List<PlaceSearchResult> _suggestions = const [];
|
||||
bool _isLoading = false;
|
||||
bool _isResolving = false;
|
||||
String? _errorMessage;
|
||||
Timer? _debounceTimer;
|
||||
int _searchSequence = 0;
|
||||
bool _showDropdown = false;
|
||||
bool _hasSearched = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.controller != null) {
|
||||
_controller = widget.controller!;
|
||||
} else {
|
||||
_controller = TextEditingController(
|
||||
text: widget.initialPlace?.displayName ?? widget.initialQuery ?? '',
|
||||
);
|
||||
_internalController = true;
|
||||
}
|
||||
|
||||
if (widget.focusNode != null) {
|
||||
_focusNode = widget.focusNode!;
|
||||
} else {
|
||||
_focusNode = FocusNode();
|
||||
_internalFocusNode = true;
|
||||
}
|
||||
|
||||
_resolvedPlace = widget.initialPlace;
|
||||
|
||||
_focusNode.addListener(_handleFocusChange);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AddressAutocompleteField oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.initialPlace != oldWidget.initialPlace) {
|
||||
_resolvedPlace = widget.initialPlace;
|
||||
final newText = widget.initialPlace?.displayName ?? '';
|
||||
if (_controller.text != newText) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _controller.text != newText) {
|
||||
_controller.text = newText;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounceTimer?.cancel();
|
||||
_focusNode.removeListener(_handleFocusChange);
|
||||
if (_internalFocusNode) {
|
||||
_focusNode.dispose();
|
||||
}
|
||||
if (_internalController) {
|
||||
_controller.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleFocusChange() {
|
||||
if (!_focusNode.hasFocus) {
|
||||
// Delay closing dropdown slightly so tap on suggestion can register
|
||||
Future.delayed(const Duration(milliseconds: 200), () {
|
||||
if (mounted && !_focusNode.hasFocus) {
|
||||
setState(() {
|
||||
_showDropdown = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onTextChanged(String text) {
|
||||
// If the text was previously resolved, modifying text invalidates the resolution
|
||||
if (_resolvedPlace != null) {
|
||||
_resolvedPlace = null;
|
||||
widget.onPlaceResolved(null);
|
||||
}
|
||||
|
||||
_debounceTimer?.cancel();
|
||||
|
||||
final trimmed = text.trim();
|
||||
if (trimmed.length < 2) {
|
||||
setState(() {
|
||||
_suggestions = const [];
|
||||
_isLoading = false;
|
||||
_showDropdown = false;
|
||||
_errorMessage = null;
|
||||
_hasSearched = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_debounceTimer = Timer(const Duration(milliseconds: 350), () {
|
||||
_performSearch(trimmed);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _performSearch(String query) async {
|
||||
final seq = ++_searchSequence;
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
_hasSearched = true;
|
||||
_showDropdown = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final provider = ref.read(activeRouteCalculationProvider);
|
||||
final results = await provider.searchPlaces(
|
||||
query,
|
||||
nearLocation: widget.nearLocation,
|
||||
);
|
||||
|
||||
if (!mounted || seq != _searchSequence) return;
|
||||
|
||||
setState(() {
|
||||
_suggestions = results;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted || seq != _searchSequence) return;
|
||||
|
||||
final msg = e is RouteProviderException
|
||||
? e.message
|
||||
: e.toString()
|
||||
.replaceAll('RouteProviderException: ', '')
|
||||
.replaceAll('Exception: ', '');
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = msg.isNotEmpty ? msg : 'Unable to search addresses.';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _selectSuggestion(PlaceSearchResult suggestion) async {
|
||||
// If coordinates are already present in search result, resolve immediately without secondary call
|
||||
if (suggestion.hasCoordinates) {
|
||||
final place = suggestion.toResolvedPlace()!;
|
||||
_applyResolvedPlace(place);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: resolve via provider
|
||||
setState(() {
|
||||
_isResolving = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final provider = ref.read(activeRouteCalculationProvider);
|
||||
final place = await provider.resolvePlace(suggestion.providerId);
|
||||
if (!mounted) return;
|
||||
_applyResolvedPlace(place);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isResolving = false;
|
||||
_errorMessage = 'Failed to resolve location coordinates.';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _applyResolvedPlace(ResolvedPlace place) {
|
||||
_resolvedPlace = place;
|
||||
_controller.text = place.displayName;
|
||||
setState(() {
|
||||
_isResolving = false;
|
||||
_suggestions = const [];
|
||||
_showDropdown = false;
|
||||
_errorMessage = null;
|
||||
});
|
||||
_focusNode.unfocus();
|
||||
widget.onPlaceResolved(place);
|
||||
}
|
||||
|
||||
void _clear() {
|
||||
_controller.clear();
|
||||
_resolvedPlace = null;
|
||||
_suggestions = const [];
|
||||
_showDropdown = false;
|
||||
_errorMessage = null;
|
||||
_hasSearched = false;
|
||||
setState(() {});
|
||||
widget.onPlaceResolved(null);
|
||||
widget.onClear?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.motorColors;
|
||||
final isResolved = _resolvedPlace != null;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
enabled: widget.enabled && !_isResolving,
|
||||
autofocus: widget.autofocus,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14),
|
||||
onChanged: _onTextChanged,
|
||||
validator: (val) {
|
||||
if (widget.isRequired && (val == null || val.trim().isEmpty)) {
|
||||
return 'This location is required';
|
||||
}
|
||||
if (widget.isRequired && _resolvedPlace == null) {
|
||||
return 'Please select a valid address from the suggestions';
|
||||
}
|
||||
if (widget.validator != null) {
|
||||
return widget.validator!(val);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.labelText,
|
||||
hintText: widget.hintText ?? 'Type address or place name...',
|
||||
labelStyle: TextStyle(
|
||||
color: isResolved ? colors.launchGreen : Colors.white70,
|
||||
fontSize: 13,
|
||||
),
|
||||
hintStyle: const TextStyle(color: Colors.white24, fontSize: 13),
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainerHigh,
|
||||
prefixIcon: widget.prefixIcon ??
|
||||
(isResolved
|
||||
? Icon(Icons.check_circle,
|
||||
color: colors.launchGreen, size: 20)
|
||||
: Icon(Icons.search, color: colors.cyberCyan, size: 20)),
|
||||
suffixIcon: _isResolving
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
),
|
||||
)
|
||||
: _controller.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear,
|
||||
size: 18, color: Colors.white54),
|
||||
onPressed: _clear,
|
||||
tooltip: 'Clear location',
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: isResolved ? colors.launchGreen : colors.outlineVariant,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: isResolved
|
||||
? colors.launchGreen.withValues(alpha: 0.6)
|
||||
: colors.outlineVariant,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: isResolved ? colors.launchGreen : colors.cyberCyan,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: colors.raceRed),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_showDropdown) ...[
|
||||
const SizedBox(height: 4),
|
||||
_buildDropdownPanel(colors),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDropdownPanel(MotorSignalThemeColors colors) {
|
||||
return Material(
|
||||
color: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
elevation: 4,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: colors.outlineVariant),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (_isLoading)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colors.cyberCyan,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'Searching addresses...',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (_errorMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded,
|
||||
color: colors.warningAmber, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _performSearch(_controller.text.trim()),
|
||||
child: Text(
|
||||
'RETRY',
|
||||
style:
|
||||
TextStyle(color: colors.cyberCyan, fontSize: 11),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (_hasSearched && _suggestions.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.location_off, color: Colors.white38, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'No matching addresses found',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
for (int i = 0; i < _suggestions.length; i++) ...[
|
||||
if (i > 0) const Divider(height: 1, color: Colors.white10),
|
||||
ListTile(
|
||||
dense: true,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
leading: Icon(
|
||||
Icons.place_outlined,
|
||||
color: colors.cyberCyan,
|
||||
size: 20,
|
||||
),
|
||||
title: Text(
|
||||
_suggestions[i].primaryText,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: _suggestions[i].secondaryText.isNotEmpty
|
||||
? Text(
|
||||
_suggestions[i].secondaryText,
|
||||
style: const TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: 11,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: null,
|
||||
onTap: () => _selectSuggestion(_suggestions[i]),
|
||||
),
|
||||
],
|
||||
if (widget.showAttribution)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
color: colors.surfaceContainer,
|
||||
child: const Text(
|
||||
'Search by Stadia Maps / Pelias • © OpenStreetMap contributors',
|
||||
style: TextStyle(
|
||||
color: Colors.white38,
|
||||
fontSize: 9,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
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/widgets/address_autocomplete_field.dart';
|
||||
import 'package:motorcycle_dash/services/routes/route_provider.dart';
|
||||
import 'package:motorcycle_dash/services/routes/route_providers.dart';
|
||||
import 'fake_route_provider.dart';
|
||||
|
||||
void main() {
|
||||
group('AddressAutocompleteField', () {
|
||||
testWidgets('debounces input and displays suggestions', (tester) async {
|
||||
final fake = FakeRouteProvider(
|
||||
customSearchResults: [
|
||||
const PlaceSearchResult(
|
||||
providerId: 'p_1',
|
||||
primaryText: 'Dahlonega Square',
|
||||
secondaryText: 'Dahlonega, GA',
|
||||
latitude: 34.5327,
|
||||
longitude: -83.9849,
|
||||
formattedAddress: '1 Public Sq, Dahlonega, GA',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
ResolvedPlace? resolved;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: Scaffold(
|
||||
body: AddressAutocompleteField(
|
||||
labelText: 'Meetup Location',
|
||||
onPlaceResolved: (p) => resolved = p,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Enter 1 char -> should not search
|
||||
await tester.enterText(find.byType(TextField), 'D');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(find.text('Dahlonega Square'), findsNothing);
|
||||
|
||||
// Enter 3 chars -> debounce 350ms -> suggestions appear
|
||||
await tester.enterText(find.byType(TextField), 'Dah');
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
// Mid-debounce -> suggestions not yet displayed
|
||||
expect(find.text('Dahlonega Square'), findsNothing);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Suggestions visible
|
||||
expect(find.text('Dahlonega Square'), findsOneWidget);
|
||||
expect(find.text('Dahlonega, GA'), findsOneWidget);
|
||||
expect(resolved, isNull);
|
||||
|
||||
// Tap suggestion to select
|
||||
await tester.tap(find.text('Dahlonega Square'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Place is now resolved
|
||||
expect(resolved, isNotNull);
|
||||
expect(resolved!.displayName, 'Dahlonega Square');
|
||||
expect(resolved!.latitude, 34.5327);
|
||||
expect(resolved!.longitude, -83.9849);
|
||||
expect(find.byIcon(Icons.check_circle), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('editing text invalidates prior resolution', (tester) async {
|
||||
final fake = FakeRouteProvider(
|
||||
customSearchResults: [
|
||||
const PlaceSearchResult(
|
||||
providerId: 'p_1',
|
||||
primaryText: 'Blood Mountain',
|
||||
secondaryText: 'Blairsville, GA',
|
||||
latitude: 34.7398,
|
||||
longitude: -83.9372,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
ResolvedPlace? resolved;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: Scaffold(
|
||||
body: AddressAutocompleteField(
|
||||
labelText: 'Destination',
|
||||
onPlaceResolved: (p) => resolved = p,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Type and select
|
||||
await tester.enterText(find.byType(TextField), 'Blood');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Blood Mountain'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(resolved, isNotNull);
|
||||
|
||||
// User edits the text -> resolution MUST be invalidated
|
||||
await tester.enterText(find.byType(TextField), 'Blood Mountain modified');
|
||||
expect(resolved, isNull);
|
||||
});
|
||||
|
||||
testWidgets('displays no-results state when search yields no matches', (tester) async {
|
||||
final fake = FakeRouteProvider(
|
||||
customSearchResults: const [],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: Scaffold(
|
||||
body: AddressAutocompleteField(
|
||||
labelText: 'Stop Location',
|
||||
onPlaceResolved: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'Nowhereville');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('No matching addresses found'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays error and retry when provider fails', (tester) async {
|
||||
final fake = FakeRouteProvider(shouldFail: true);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: Scaffold(
|
||||
body: AddressAutocompleteField(
|
||||
labelText: 'Meetup Location',
|
||||
onPlaceResolved: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'ErrorPlace');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Fake search error'), findsOneWidget);
|
||||
expect(find.text('RETRY'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('clearing text resets state and invalidates resolution', (tester) async {
|
||||
final fake = FakeRouteProvider();
|
||||
ResolvedPlace? resolved;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: Scaffold(
|
||||
body: AddressAutocompleteField(
|
||||
labelText: 'Meetup Location',
|
||||
onPlaceResolved: (p) => resolved = p,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'Dahlonega');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.textContaining('Fake Result 1'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(resolved, isNotNull);
|
||||
|
||||
// Tap clear icon
|
||||
await tester.tap(find.byIcon(Icons.clear));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(resolved, isNull);
|
||||
expect(find.text(''), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('resolves place via provider if coordinates are missing in search result', (tester) async {
|
||||
final fake = FakeRouteProvider(
|
||||
customSearchResults: [
|
||||
const PlaceSearchResult(
|
||||
providerId: 'need_coords_id',
|
||||
primaryText: 'Neels Gap Store',
|
||||
secondaryText: 'Blairsville, GA',
|
||||
// No coordinates
|
||||
),
|
||||
],
|
||||
customResolvedPlace: const ResolvedPlace(
|
||||
providerId: 'need_coords_id',
|
||||
displayName: 'Neels Gap Store',
|
||||
latitude: 34.8698,
|
||||
longitude: -83.9182,
|
||||
),
|
||||
);
|
||||
|
||||
ResolvedPlace? resolved;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: Scaffold(
|
||||
body: AddressAutocompleteField(
|
||||
labelText: 'Destination',
|
||||
onPlaceResolved: (p) => resolved = p,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'Neels');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Neels Gap Store'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(resolved, isNotNull);
|
||||
expect(resolved!.latitude, 34.8698);
|
||||
expect(resolved!.longitude, -83.9182);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,962 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'dart:async';
|
||||
import 'package:motorcycle_dash/features/group_rides/create_group_ride_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/group_rides/group_ride_models.dart';
|
||||
import 'package:motorcycle_dash/services/group_rides/group_ride_service.dart';
|
||||
import 'package:motorcycle_dash/services/group_rides/group_ride_providers.dart';
|
||||
import 'package:motorcycle_dash/services/routes/route_models.dart';
|
||||
import 'package:motorcycle_dash/services/routes/route_provider.dart';
|
||||
import 'package:motorcycle_dash/services/routes/route_providers.dart';
|
||||
import 'package:motorcycle_dash/services/social/rider_profile_models.dart';
|
||||
import 'package:motorcycle_dash/services/social/social_providers.dart';
|
||||
import 'package:motorcycle_dash/features/routes/widgets/address_autocomplete_field.dart';
|
||||
import 'fake_route_provider.dart';
|
||||
|
||||
class _MockGroupRideService extends GroupRideService {
|
||||
GroupRide? createdRide;
|
||||
|
||||
@override
|
||||
Future<GroupRide> createGroupRide({
|
||||
required RiderProfile organizer,
|
||||
required String title,
|
||||
String description = '',
|
||||
GroupRideVisibility visibility = GroupRideVisibility.friendsOnly,
|
||||
GroupRideJoinPolicy joinPolicy = GroupRideJoinPolicy.inviteOnly,
|
||||
required DateTime scheduledStartAt,
|
||||
required DateTime departureAt,
|
||||
int? participantLimit,
|
||||
required GroupRideLocation meetingPoint,
|
||||
GroupRideLocation? destination,
|
||||
List<GroupRideLocation> stops = const [],
|
||||
List<String> initialInviteUids = const [],
|
||||
String? routeId,
|
||||
String? routeTitle,
|
||||
double? routeDistanceMeters,
|
||||
int? routeStopCount,
|
||||
}) async {
|
||||
final now = DateTime.now();
|
||||
createdRide = GroupRide(
|
||||
id: 'ride_new_test',
|
||||
organizerUid: organizer.uid,
|
||||
organizerHandle: organizer.handle,
|
||||
organizerDisplayName: organizer.displayName,
|
||||
title: title,
|
||||
description: description,
|
||||
visibility: visibility,
|
||||
joinPolicy: joinPolicy,
|
||||
scheduledStartAt: scheduledStartAt,
|
||||
departureAt: departureAt,
|
||||
participantLimit: participantLimit,
|
||||
meetingPoint: meetingPoint,
|
||||
destination: destination,
|
||||
stops: stops,
|
||||
routeId: routeId,
|
||||
routeTitle: routeTitle,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
return createdRide!;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('CreateGroupRideScreen Route-P2 Tests', () {
|
||||
late RiderProfile profile;
|
||||
late AuthUser user;
|
||||
|
||||
setUp(() {
|
||||
final now = DateTime.now();
|
||||
profile = RiderProfile(
|
||||
uid: 'alice',
|
||||
handle: 'alice_apex',
|
||||
displayName: 'Alice Apex',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
user = const AuthUser(
|
||||
uid: 'alice',
|
||||
email: 'alice@example.com',
|
||||
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('ambiguous AppBar Import icon is removed and USE EXISTING ROUTE action is visible',
|
||||
(tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(400, 900));
|
||||
|
||||
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
currentUserProvider.overrideWithValue(user),
|
||||
myRiderProfileProvider.overrideWith((ref) => Stream.value(RiderProfile(uid: 'alice', handle: 'alice_apex', displayName: 'Alice Apex', createdAt: DateTime.now(), updatedAt: DateTime.now()))),
|
||||
],
|
||||
child: const MaterialApp(debugShowCheckedModeBanner: false,
|
||||
home: CreateGroupRideScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify ambiguous AppBar icon is removed
|
||||
final appBarFinder = find.byType(AppBar);
|
||||
expect(
|
||||
find.descendant(of: appBarFinder, matching: find.byIcon(Icons.alt_route)),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
// Verify explicit form button "USE EXISTING ROUTE" is visible
|
||||
expect(find.text('USE EXISTING ROUTE'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('blocks publish when Meetup or Destination is unresolved text',
|
||||
(tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
final mockService = _MockGroupRideService();
|
||||
|
||||
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
currentUserProvider.overrideWithValue(user),
|
||||
myRiderProfileProvider.overrideWith((ref) => Stream.value(RiderProfile(uid: 'alice', handle: 'alice_apex', displayName: 'Alice Apex', createdAt: DateTime.now(), updatedAt: DateTime.now()))),
|
||||
groupRideServiceProvider.overrideWithValue(mockService),
|
||||
],
|
||||
child: const MaterialApp(debugShowCheckedModeBanner: false,
|
||||
home: CreateGroupRideScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Enter valid title
|
||||
final titleField = find.widgetWithText(TextFormField, 'Ride Title');
|
||||
await tester.enterText(titleField, 'Weekend Cruise');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Enter unresolved text into Meetup field
|
||||
final startSearchField = find.widgetWithText(
|
||||
AddressAutocompleteField,
|
||||
'Meeting Place Address / Location *',
|
||||
);
|
||||
final startInput = find.descendant(
|
||||
of: startSearchField,
|
||||
matching: find.byType(TextFormField),
|
||||
);
|
||||
await tester.enterText(startInput, 'Unresolved Meetup Spot');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Do not select resolved meetup from suggestions, just tap PUBLISH
|
||||
await tester.tap(find.text('PUBLISH'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Validation error shown
|
||||
expect(
|
||||
find.textContaining('Please select a valid address from the suggestions'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(mockService.createdRide, isNull);
|
||||
});
|
||||
|
||||
testWidgets('calculates route and displays preview when Start and Destination resolved',
|
||||
(tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
final fake = FakeRouteProvider(
|
||||
customSearchResults: [
|
||||
const PlaceSearchResult(
|
||||
providerId: 'meetup_1',
|
||||
primaryText: 'Dahlonega Chevron',
|
||||
secondaryText: 'Dahlonega, GA',
|
||||
latitude: 34.53,
|
||||
longitude: -83.98,
|
||||
),
|
||||
const PlaceSearchResult(
|
||||
providerId: 'dest_1',
|
||||
primaryText: 'Neels Gap',
|
||||
secondaryText: 'Blairsville, GA',
|
||||
latitude: 34.88,
|
||||
longitude: -83.92,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
currentUserProvider.overrideWithValue(user),
|
||||
myRiderProfileProvider.overrideWith((ref) => Stream.value(RiderProfile(uid: 'alice', handle: 'alice_apex', displayName: 'Alice Apex', createdAt: DateTime.now(), updatedAt: DateTime.now()))),
|
||||
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||
],
|
||||
child: const MaterialApp(debugShowCheckedModeBanner: false,
|
||||
home: CreateGroupRideScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Fill Start address
|
||||
final startSearchField = find.widgetWithText(
|
||||
AddressAutocompleteField,
|
||||
'Meeting Place Address / Location *',
|
||||
);
|
||||
final startInput = find.descendant(
|
||||
of: startSearchField,
|
||||
matching: find.byType(TextFormField),
|
||||
);
|
||||
await tester.enterText(startInput, 'Dahlonega');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Dahlonega Chevron').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Fill Destination address
|
||||
final destSearchField = find.widgetWithText(
|
||||
AddressAutocompleteField,
|
||||
'Destination Address / Location *',
|
||||
);
|
||||
final destInput = find.descendant(
|
||||
of: destSearchField,
|
||||
matching: find.byType(TextFormField),
|
||||
);
|
||||
await tester.enterText(destInput, 'Neels');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Neels Gap').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify route preview section rendered with distance
|
||||
expect(find.text('ROUTE PREVIEW'), findsOneWidget);
|
||||
expect(find.textContaining('Road Est.'), findsOneWidget);
|
||||
expect(find.text('Powered by Valhalla • Stadia Maps'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('allows adding, editing, and removing intermediate stops',
|
||||
(tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
final fake = FakeRouteProvider(
|
||||
customSearchResults: [
|
||||
const PlaceSearchResult(
|
||||
providerId: 'stop_p1',
|
||||
primaryText: 'Turner Corner Gas',
|
||||
secondaryText: 'Cleveland, GA',
|
||||
latitude: 34.68,
|
||||
longitude: -83.89,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
currentUserProvider.overrideWithValue(user),
|
||||
myRiderProfileProvider.overrideWith((ref) => Stream.value(RiderProfile(uid: 'alice', handle: 'alice_apex', displayName: 'Alice Apex', createdAt: DateTime.now(), updatedAt: DateTime.now()))),
|
||||
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||
],
|
||||
child: const MaterialApp(debugShowCheckedModeBanner: false,
|
||||
home: CreateGroupRideScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap ADD STOP
|
||||
await tester.tap(find.text('ADD STOP'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Add Intermediate Stop'), findsOneWidget);
|
||||
|
||||
// Search stop address
|
||||
final dialogSearch = find.descendant(
|
||||
of: find.byType(AlertDialog),
|
||||
matching: find.byType(AddressAutocompleteField),
|
||||
);
|
||||
final dialogSearchInput = find.descendant(
|
||||
of: dialogSearch,
|
||||
matching: find.byType(TextFormField),
|
||||
);
|
||||
await tester.enterText(dialogSearchInput, 'Turner');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Turner Corner Gas'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Add Stop
|
||||
await tester.tap(find.text('ADD STOP').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify stop added in intermediate list
|
||||
expect(find.text('Turner Corner Gas'), findsOneWidget);
|
||||
expect(find.textContaining('Intermediate Stops (2/25 max)'), findsOneWidget);
|
||||
|
||||
// Delete stop
|
||||
await tester.tap(find.byIcon(Icons.delete_outline));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Turner Corner Gas'), findsNothing);
|
||||
expect(find.textContaining('Intermediate Stops (1/25 max)'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('USE EXISTING ROUTE loads saved route stops and recalculates route',
|
||||
(tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
final savedRoute = MotorSignalRoute(
|
||||
id: 'route_saved_1',
|
||||
title: 'Georgia Mountain Loop',
|
||||
description: 'Twisty scenic mountain ride',
|
||||
ownerUid: 'alice',
|
||||
ownerHandle: 'alice_apex',
|
||||
visibility: RouteVisibility.public,
|
||||
straightLineDistanceMeters: 55000,
|
||||
stops: const [
|
||||
RouteStop(
|
||||
id: 's_start',
|
||||
displayName: 'Suches Community Center',
|
||||
latitude: 34.689,
|
||||
longitude: -84.025,
|
||||
order: 0,
|
||||
stopType: RouteStopType.origin,
|
||||
),
|
||||
RouteStop(
|
||||
id: 's_mid',
|
||||
displayName: 'Vogel State Park',
|
||||
latitude: 34.764,
|
||||
longitude: -83.924,
|
||||
order: 1,
|
||||
stopType: RouteStopType.waypoint,
|
||||
),
|
||||
RouteStop(
|
||||
id: 's_end',
|
||||
displayName: 'Two Wheels of Suches',
|
||||
latitude: 34.693,
|
||||
longitude: -84.023,
|
||||
order: 2,
|
||||
stopType: RouteStopType.destination,
|
||||
),
|
||||
],
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
final fake = FakeRouteProvider();
|
||||
|
||||
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
currentUserProvider.overrideWithValue(user),
|
||||
myRiderProfileProvider.overrideWith((ref) => Stream.value(RiderProfile(uid: 'alice', handle: 'alice_apex', displayName: 'Alice Apex', createdAt: DateTime.now(), updatedAt: DateTime.now()))),
|
||||
userRoutesProvider('alice').overrideWith((ref) => Stream.value([savedRoute])),
|
||||
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||
],
|
||||
child: const MaterialApp(debugShowCheckedModeBanner: false,
|
||||
home: CreateGroupRideScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap USE EXISTING ROUTE
|
||||
await tester.tap(find.text('USE EXISTING ROUTE'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify bottom sheet appears
|
||||
expect(find.text('Georgia Mountain Loop'), findsOneWidget);
|
||||
|
||||
// Select route
|
||||
await tester.tap(find.text('Georgia Mountain Loop'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify fields populated
|
||||
expect(find.text('Georgia Mountain Loop'), findsWidgets);
|
||||
expect(find.text('Suches Community Center'), findsWidgets);
|
||||
expect(find.text('Vogel State Park'), findsOneWidget);
|
||||
expect(find.text('Two Wheels of Suches'), findsWidgets);
|
||||
expect(find.text('Route: Georgia Mountain Loop'), findsOneWidget);
|
||||
|
||||
// Verify route preview recalculated
|
||||
expect(find.text('ROUTE PREVIEW'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('submits and publishes GroupRide with valid resolved locations and stops',
|
||||
(tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
final mockService = _MockGroupRideService();
|
||||
final fake = FakeRouteProvider(
|
||||
customSearchResults: [
|
||||
const PlaceSearchResult(
|
||||
providerId: 'meetup_1',
|
||||
primaryText: 'Dahlonega Chevron',
|
||||
secondaryText: 'Dahlonega, GA',
|
||||
latitude: 34.53,
|
||||
longitude: -83.98,
|
||||
),
|
||||
const PlaceSearchResult(
|
||||
providerId: 'dest_1',
|
||||
primaryText: 'Neels Gap',
|
||||
secondaryText: 'Blairsville, GA',
|
||||
latitude: 34.88,
|
||||
longitude: -83.92,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
currentUserProvider.overrideWithValue(user),
|
||||
myRiderProfileProvider.overrideWith((ref) => Stream.value(RiderProfile(uid: 'alice', handle: 'alice_apex', displayName: 'Alice Apex', createdAt: DateTime.now(), updatedAt: DateTime.now()))),
|
||||
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||
groupRideServiceProvider.overrideWithValue(mockService),
|
||||
],
|
||||
child: const MaterialApp(debugShowCheckedModeBanner: false,
|
||||
home: CreateGroupRideScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Fill title
|
||||
final titleField = find.widgetWithText(TextFormField, 'Ride Title');
|
||||
await tester.enterText(titleField, 'Blood Mountain Fast Run');
|
||||
|
||||
// Meetup
|
||||
final startSearchField = find.widgetWithText(
|
||||
AddressAutocompleteField,
|
||||
'Meeting Place Address / Location *',
|
||||
);
|
||||
final startInput = find.descendant(
|
||||
of: startSearchField,
|
||||
matching: find.byType(TextFormField),
|
||||
);
|
||||
await tester.enterText(startInput, 'Dahlonega');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Dahlonega Chevron').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Destination
|
||||
final destSearchField = find.widgetWithText(
|
||||
AddressAutocompleteField,
|
||||
'Destination Address / Location *',
|
||||
);
|
||||
final destInput = find.descendant(
|
||||
of: destSearchField,
|
||||
matching: find.byType(TextFormField),
|
||||
);
|
||||
await tester.enterText(destInput, 'Neels');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Neels Gap').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap PUBLISH
|
||||
await tester.tap(find.text('PUBLISH'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify ride created with correct coordinates
|
||||
expect(mockService.createdRide, isNotNull);
|
||||
expect(mockService.createdRide!.title, 'Blood Mountain Fast Run');
|
||||
expect(mockService.createdRide!.meetingPoint.displayName, 'Dahlonega Chevron');
|
||||
expect(mockService.createdRide!.meetingPoint.latitude, 34.53);
|
||||
expect(mockService.createdRide!.destination!.displayName, 'Neels Gap');
|
||||
expect(mockService.createdRide!.destination!.latitude, 34.88);
|
||||
});
|
||||
});
|
||||
group('ROUTE-P2 Final Pre-Commit Forensic Gate', () {
|
||||
testWidgets('VERIFY EXACT ROUTE ORDER: Start + Destination', (tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
final mockService = _MockGroupRideService();
|
||||
final fakeRouteProvider = FakeRouteProvider(
|
||||
customSearchResults: [
|
||||
const PlaceSearchResult(providerId: 'meetup_1', primaryText: 'Dahlonega Chevron', secondaryText: 'Dahlonega, GA', latitude: 34.53, longitude: -83.98),
|
||||
const PlaceSearchResult(providerId: 'stop_2', primaryText: 'Stop 2 Place', secondaryText: 'Stop 2 City', latitude: 34.70, longitude: -83.90),
|
||||
const PlaceSearchResult(providerId: 'dest_1', primaryText: 'Neels Gap', secondaryText: 'Blood Mountain', latitude: 34.88, longitude: -83.99),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
groupRideServiceProvider.overrideWithValue(mockService),
|
||||
myRiderProfileProvider.overrideWith((_) => Stream.value(RiderProfile(
|
||||
uid: 'uid_123',
|
||||
handle: 'testrider', displayName: 'Test Rider', createdAt: DateTime.now(), updatedAt: DateTime.now(),
|
||||
))),
|
||||
activeRouteCalculationProvider.overrideWithValue(fakeRouteProvider),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: Material(
|
||||
child: CreateGroupRideScreen(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Meetup
|
||||
final searchField = find.widgetWithText(AddressAutocompleteField, 'Meeting Place Address / Location *');
|
||||
final input = find.descendant(of: searchField, matching: find.byType(TextFormField));
|
||||
await tester.enterText(input, 'Start');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Dahlonega Chevron').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Destination (already visible, hasDestination=true by default)
|
||||
final destSearchField = find.widgetWithText(AddressAutocompleteField, 'Destination Address / Location *');
|
||||
final destInput = find.descendant(of: destSearchField, matching: find.byType(TextFormField));
|
||||
await tester.enterText(destInput, 'Neels');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Neels Gap').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(fakeRouteProvider.lastCapturedStops, isNotNull);
|
||||
final stops = fakeRouteProvider.lastCapturedStops!;
|
||||
|
||||
expect(stops.length, 2);
|
||||
expect(stops[0].stopType, RouteStopType.origin);
|
||||
expect(stops[0].displayName, 'Dahlonega Chevron');
|
||||
|
||||
expect(stops[1].stopType, RouteStopType.destination);
|
||||
expect(stops[1].displayName, 'Neels Gap');
|
||||
});
|
||||
|
||||
testWidgets('VERIFY EXACT ROUTE ORDER: Start + Stop1 + Destination', (tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
final mockService = _MockGroupRideService();
|
||||
final fakeRouteProvider = FakeRouteProvider(
|
||||
customSearchResults: [
|
||||
const PlaceSearchResult(providerId: 'meetup_1', primaryText: 'Dahlonega Chevron', secondaryText: 'Dahlonega, GA', latitude: 34.53, longitude: -83.98),
|
||||
const PlaceSearchResult(providerId: 'stop_2', primaryText: 'Stop 2 Place', secondaryText: 'Stop 2 City', latitude: 34.70, longitude: -83.90),
|
||||
const PlaceSearchResult(providerId: 'dest_1', primaryText: 'Neels Gap', secondaryText: 'Blood Mountain', latitude: 34.88, longitude: -83.99),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
groupRideServiceProvider.overrideWithValue(mockService),
|
||||
myRiderProfileProvider.overrideWith((_) => Stream.value(RiderProfile(
|
||||
uid: 'uid_123',
|
||||
handle: 'testrider', displayName: 'Test Rider', createdAt: DateTime.now(), updatedAt: DateTime.now(),
|
||||
))),
|
||||
activeRouteCalculationProvider.overrideWithValue(fakeRouteProvider),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: Material(
|
||||
child: CreateGroupRideScreen(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Meetup
|
||||
final searchField = find.widgetWithText(AddressAutocompleteField, 'Meeting Place Address / Location *');
|
||||
final input = find.descendant(of: searchField, matching: find.byType(TextFormField));
|
||||
await tester.enterText(input, 'Start');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Dahlonega Chevron').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Stop 1
|
||||
await tester.dragUntilVisible(find.text('ADD STOP').first, find.byType(Scrollable).first, const Offset(0, -100)); await tester.pumpAndSettle(); await tester.tap(find.text('ADD STOP').first);
|
||||
await tester.pumpAndSettle();
|
||||
final stopSearchField = find.widgetWithText(AddressAutocompleteField, 'Stop Address / Location *');
|
||||
final stopInput = find.descendant(of: stopSearchField, matching: find.byType(TextFormField));
|
||||
await tester.enterText(stopInput, 'Stop1');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Dahlonega Chevron').last); // Fake returns this for everything
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('ADD STOP').last); // Add button in dialog
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Destination
|
||||
final destSearchField = find.widgetWithText(AddressAutocompleteField, 'Destination Address / Location *');
|
||||
final destInput = find.descendant(of: destSearchField, matching: find.byType(TextFormField));
|
||||
await tester.enterText(destInput, 'Neels');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Neels Gap').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(fakeRouteProvider.lastCapturedStops, isNotNull);
|
||||
final stops = fakeRouteProvider.lastCapturedStops!;
|
||||
|
||||
expect(stops.length, 3);
|
||||
expect(stops[0].stopType, RouteStopType.origin);
|
||||
expect(stops[1].stopType, RouteStopType.waypoint);
|
||||
expect(stops[2].stopType, RouteStopType.destination);
|
||||
});
|
||||
|
||||
testWidgets('VERIFY EXACT ROUTE ORDER: Start + Stop1 + Stop2 + Destination', (tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
final mockService = _MockGroupRideService();
|
||||
final fakeRouteProvider = FakeRouteProvider(
|
||||
customSearchResults: [
|
||||
const PlaceSearchResult(providerId: 'meetup_1', primaryText: 'Dahlonega Chevron', secondaryText: 'Dahlonega, GA', latitude: 34.53, longitude: -83.98),
|
||||
const PlaceSearchResult(providerId: 'stop_2', primaryText: 'Stop 2 Place', secondaryText: 'Stop 2 City', latitude: 34.70, longitude: -83.90),
|
||||
const PlaceSearchResult(providerId: 'dest_1', primaryText: 'Neels Gap', secondaryText: 'Blood Mountain', latitude: 34.88, longitude: -83.99),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
groupRideServiceProvider.overrideWithValue(mockService),
|
||||
myRiderProfileProvider.overrideWith((_) => Stream.value(RiderProfile(
|
||||
uid: 'uid_123',
|
||||
handle: 'testrider', displayName: 'Test Rider', createdAt: DateTime.now(), updatedAt: DateTime.now(),
|
||||
))),
|
||||
activeRouteCalculationProvider.overrideWithValue(fakeRouteProvider),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: Material(
|
||||
child: CreateGroupRideScreen(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Meetup
|
||||
final searchField = find.widgetWithText(AddressAutocompleteField, 'Meeting Place Address / Location *');
|
||||
final input = find.descendant(of: searchField, matching: find.byType(TextFormField));
|
||||
await tester.enterText(input, 'Start');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Dahlonega Chevron').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Stop 1
|
||||
await tester.dragUntilVisible(find.text('ADD STOP').first, find.byType(Scrollable).first, const Offset(0, -100)); await tester.pumpAndSettle(); await tester.tap(find.text('ADD STOP').first);
|
||||
await tester.pumpAndSettle();
|
||||
final stopSearchField1 = find.widgetWithText(AddressAutocompleteField, 'Stop Address / Location *');
|
||||
final stopInput1 = find.descendant(of: stopSearchField1, matching: find.byType(TextFormField));
|
||||
await tester.enterText(stopInput1, 'Stop1');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Dahlonega Chevron').last);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('ADD STOP').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Stop 2
|
||||
await tester.dragUntilVisible(find.text('ADD STOP').first, find.byType(Scrollable).first, const Offset(0, -100)); await tester.pumpAndSettle(); await tester.tap(find.text('ADD STOP').first);
|
||||
await tester.pumpAndSettle();
|
||||
final stopSearchFields = find.widgetWithText(AddressAutocompleteField, 'Stop Address / Location *');
|
||||
final stopInput2 = find.descendant(of: stopSearchFields.last, matching: find.byType(TextFormField));
|
||||
await tester.enterText(stopInput2, 'Stop2');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Stop 2 Place').last);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('ADD STOP').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Destination
|
||||
final destSearchField = find.widgetWithText(AddressAutocompleteField, 'Destination Address / Location *');
|
||||
final destInput = find.descendant(of: destSearchField, matching: find.byType(TextFormField));
|
||||
await tester.enterText(destInput, 'Neels');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Neels Gap').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(fakeRouteProvider.lastCapturedStops, isNotNull);
|
||||
final stops = fakeRouteProvider.lastCapturedStops!;
|
||||
|
||||
expect(stops.length, 4);
|
||||
expect(stops[0].stopType, RouteStopType.origin);
|
||||
expect(stops[1].stopType, RouteStopType.waypoint);
|
||||
expect(stops[2].stopType, RouteStopType.waypoint);
|
||||
expect(stops[3].stopType, RouteStopType.destination);
|
||||
});
|
||||
});
|
||||
|
||||
group('ROUTE-P2 REAL RESPONSIVE TESTS', () {
|
||||
testWidgets('Pixel-like portrait (412x915)', (tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(412, 915));
|
||||
final fakeRouteProvider = FakeRouteProvider(
|
||||
customSearchResults: [
|
||||
const PlaceSearchResult(providerId: 'meetup_1', primaryText: 'Result 1', secondaryText: 'City 1', latitude: 34.1, longitude: -83.1),
|
||||
],
|
||||
);
|
||||
|
||||
final now = DateTime.now();
|
||||
final profile = RiderProfile(uid: 'alice', handle: 'alice_apex', displayName: 'Alice Apex', createdAt: now, updatedAt: now);
|
||||
final user = const AuthUser(uid: 'alice', email: 'alice@example.com');
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
currentUserProvider.overrideWithValue(user),
|
||||
myRiderProfileProvider.overrideWith((ref) => Stream.value(profile)),
|
||||
activeRouteCalculationProvider.overrideWithValue(fakeRouteProvider),
|
||||
],
|
||||
);
|
||||
|
||||
addTearDown(() {
|
||||
tester.binding.setSurfaceSize(null);
|
||||
container.dispose();
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: MaterialApp(
|
||||
home: const CreateGroupRideScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final searchFields = find.widgetWithText(AddressAutocompleteField, 'Meeting Place Address / Location *');
|
||||
final meetupInput = find.descendant(of: searchFields.first, matching: find.byType(TextFormField));
|
||||
await tester.enterText(meetupInput, 'Start');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
for (int i = 0; i < 20; i++) {
|
||||
if (tester.any(find.text('ADD STOP').first)) {
|
||||
final dy = tester.getCenter(find.text('ADD STOP').first).dy;
|
||||
if (dy > 100 && dy < 300) break;
|
||||
}
|
||||
await tester.drag(find.byType(Scrollable).first, const Offset(0, -100));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
await tester.tap(find.text('ADD STOP').first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final stopFields = find.widgetWithText(AddressAutocompleteField, 'Stop Address / Location *');
|
||||
final stopInput = find.descendant(of: stopFields.last, matching: find.byType(TextFormField));
|
||||
await tester.enterText(stopInput, 'Stop1');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('CANCEL'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
for (int i = 0; i < 20; i++) {
|
||||
final destSearchField = find.widgetWithText(AddressAutocompleteField, 'Destination Address / Location *');
|
||||
if (tester.any(destSearchField)) {
|
||||
final dy = tester.getCenter(destSearchField).dy;
|
||||
if (dy > 100 && dy < 300) break;
|
||||
}
|
||||
await tester.drag(find.byType(Scrollable).first, const Offset(0, -100));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
final destSearchField = find.widgetWithText(AddressAutocompleteField, 'Destination Address / Location *');
|
||||
final destInput = find.descendant(of: destSearchField, matching: find.byType(TextFormField));
|
||||
await tester.enterText(destInput, 'Dest');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('Short landscape (915x412)', (tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(915, 412));
|
||||
final fakeRouteProvider = FakeRouteProvider(
|
||||
customSearchResults: [
|
||||
const PlaceSearchResult(providerId: 'meetup_1', primaryText: 'Result 1', secondaryText: 'City 1', latitude: 34.1, longitude: -83.1),
|
||||
],
|
||||
);
|
||||
|
||||
final now = DateTime.now();
|
||||
final profile = RiderProfile(uid: 'alice', handle: 'alice_apex', displayName: 'Alice Apex', createdAt: now, updatedAt: now);
|
||||
final user = const AuthUser(uid: 'alice', email: 'alice@example.com');
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
currentUserProvider.overrideWithValue(user),
|
||||
myRiderProfileProvider.overrideWith((ref) => Stream.value(profile)),
|
||||
activeRouteCalculationProvider.overrideWithValue(fakeRouteProvider),
|
||||
],
|
||||
);
|
||||
|
||||
addTearDown(() {
|
||||
tester.binding.setSurfaceSize(null);
|
||||
container.dispose();
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: MaterialApp(
|
||||
home: const CreateGroupRideScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final searchFields = find.widgetWithText(AddressAutocompleteField, 'Meeting Place Address / Location *');
|
||||
final meetupInput = find.descendant(of: searchFields.first, matching: find.byType(TextFormField));
|
||||
await tester.enterText(meetupInput, 'Start');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
for (int i = 0; i < 20; i++) {
|
||||
if (tester.any(find.text('ADD STOP').first)) {
|
||||
final dy = tester.getCenter(find.text('ADD STOP').first).dy;
|
||||
if (dy > 100 && dy < 300) break;
|
||||
}
|
||||
await tester.drag(find.byType(Scrollable).first, const Offset(0, -100));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
await tester.tap(find.text('ADD STOP').first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final stopFields = find.widgetWithText(AddressAutocompleteField, 'Stop Address / Location *');
|
||||
final stopInput = find.descendant(of: stopFields.last, matching: find.byType(TextFormField));
|
||||
await tester.enterText(stopInput, 'Stop1');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('CANCEL'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
for (int i = 0; i < 20; i++) {
|
||||
final destSearchField = find.widgetWithText(AddressAutocompleteField, 'Destination Address / Location *');
|
||||
if (tester.any(destSearchField)) {
|
||||
final dy = tester.getCenter(destSearchField).dy;
|
||||
if (dy > 100 && dy < 300) break;
|
||||
}
|
||||
await tester.drag(find.byType(Scrollable).first, const Offset(0, -100));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
final destSearchField = find.widgetWithText(AddressAutocompleteField, 'Destination Address / Location *');
|
||||
final destInput = find.descendant(of: destSearchField, matching: find.byType(TextFormField));
|
||||
await tester.enterText(destInput, 'Dest');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
testWidgets('ROUTE RACE - In-flight calculation is discarded if sequence advances', (tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
Completer<void>? calc1Completer;
|
||||
|
||||
final fakeRouteProvider = FakeRouteProvider(
|
||||
customSearchResults: [
|
||||
const PlaceSearchResult(providerId: 'meetup_1', primaryText: 'Start', secondaryText: 'City 1', latitude: 34.1, longitude: -83.1),
|
||||
const PlaceSearchResult(providerId: 'dest_1', primaryText: 'Dest1', secondaryText: 'City 2', latitude: 34.2, longitude: -83.2),
|
||||
],
|
||||
onCalculate: (count) async {
|
||||
print('ON CALCULATE CALLED WITH COUNT: '); if (count == 0) {
|
||||
calc1Completer = Completer<void>();
|
||||
await calc1Completer!.future;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
activeRouteCalculationProvider.overrideWithValue(fakeRouteProvider),
|
||||
currentUserProvider.overrideWithValue(const AuthUser(uid: 'alice', email: 'alice@example.com')),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const MaterialApp(home: CreateGroupRideScreen()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Step 1: Add Meetup
|
||||
final meetupField = find.widgetWithText(AddressAutocompleteField, 'Meeting Place Address / Location *');
|
||||
await tester.enterText(find.descendant(of: meetupField, matching: find.byType(TextFormField)), 'Start');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Start').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Step 2: Add Destination
|
||||
final destField = find.widgetWithText(AddressAutocompleteField, 'Destination Address / Location *');
|
||||
await tester.enterText(find.descendant(of: destField, matching: find.byType(TextFormField)), 'Dest1');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap Dest1 -> triggers Calculation 0 (which stalls because count == 0)
|
||||
await tester.tap(find.text('Dest1').last);
|
||||
await tester.pump(); // Start calc
|
||||
|
||||
// Step 3: While Calc 0 is stalled, clear the destination!
|
||||
// This triggers sequence increment and an early return.
|
||||
await tester.enterText(find.descendant(of: destField, matching: find.byType(TextFormField)), '');
|
||||
|
||||
// Tap the 'clear' icon if there is one, or just unfocus
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Step 4: Now finish Calc 0!
|
||||
calc1Completer?.complete();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verification: The stale Calc 0 should NOT be shown!
|
||||
// The UI should show no distance label.
|
||||
expect(find.textContaining('miles'), findsNothing);
|
||||
expect(find.textContaining('km'), findsNothing);
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
});
|
||||
}
|
||||
|
|
@ -1,14 +1,24 @@
|
|||
import 'package:motorcycle_dash/services/map/map_provider_interface.dart';
|
||||
import 'package:motorcycle_dash/services/routes/route_models.dart';
|
||||
import 'package:motorcycle_dash/services/routes/route_provider.dart';
|
||||
import 'dart:async';
|
||||
|
||||
class FakeRouteProvider implements RouteProvider {
|
||||
final bool shouldFail;
|
||||
final bool shouldTimeout;
|
||||
final List<PlaceSearchResult>? customSearchResults;
|
||||
final ResolvedPlace? customResolvedPlace;
|
||||
final Future<void> Function(int count)? onCalculate;
|
||||
int _calcCount = 0;
|
||||
|
||||
List<RouteStop>? lastCapturedStops;
|
||||
|
||||
FakeRouteProvider({
|
||||
this.shouldFail = false,
|
||||
this.shouldTimeout = false,
|
||||
this.customSearchResults,
|
||||
this.customResolvedPlace,
|
||||
this.onCalculate,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -21,6 +31,12 @@ class FakeRouteProvider implements RouteProvider {
|
|||
bool avoidTolls = false,
|
||||
bool avoidFerries = false,
|
||||
}) async {
|
||||
lastCapturedStops = stops;
|
||||
|
||||
if (onCalculate != null) {
|
||||
await onCalculate!(_calcCount++);
|
||||
}
|
||||
|
||||
if (shouldTimeout) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
throw RouteProviderException('providerTimeout', 'Fake timeout');
|
||||
|
|
@ -54,24 +70,17 @@ class FakeRouteProvider implements RouteProvider {
|
|||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ResolvedPlace> resolvePlace(String placeId) async {
|
||||
if (shouldFail) throw RouteProviderException('placeNotFound', 'Fake not found');
|
||||
return ResolvedPlace(
|
||||
providerId: placeId,
|
||||
displayName: 'Fake Place',
|
||||
latitude: 0.0,
|
||||
longitude: 0.0,
|
||||
attribution: 'Fake Attribution',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PlaceSearchResult>> searchPlaces(
|
||||
String query, {
|
||||
AppLatLng? nearLocation,
|
||||
}) async {
|
||||
if (shouldFail) throw RouteProviderException('placeSearchUnavailable', 'Fake search error');
|
||||
if (shouldFail) {
|
||||
throw RouteProviderException('placeSearchUnavailable', 'Fake search error');
|
||||
}
|
||||
|
||||
if (customSearchResults != null) return customSearchResults!;
|
||||
|
||||
if (query.length < 2) return [];
|
||||
|
||||
return [
|
||||
|
|
@ -83,4 +92,19 @@ class FakeRouteProvider implements RouteProvider {
|
|||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ResolvedPlace> resolvePlace(String providerPlaceId) async {
|
||||
if (shouldFail) {
|
||||
throw RouteProviderException('placeNotFound', 'Fake not found');
|
||||
}
|
||||
if (customResolvedPlace != null) return customResolvedPlace!;
|
||||
return ResolvedPlace(
|
||||
providerId: providerPlaceId,
|
||||
displayName: 'Fake Place',
|
||||
latitude: 0.0,
|
||||
longitude: 0.0,
|
||||
attribution: 'Fake Attribution',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,299 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:motorcycle_dash/features/group_rides/live_group_ride_screen.dart';
|
||||
import 'package:motorcycle_dash/services/auth/auth_user.dart';
|
||||
import 'package:motorcycle_dash/services/auth/auth_providers.dart';
|
||||
import 'package:motorcycle_dash/services/group_rides/group_ride_models.dart';
|
||||
import 'package:motorcycle_dash/services/group_rides/group_ride_providers.dart';
|
||||
import 'package:motorcycle_dash/services/group_rides/group_ride_service.dart';
|
||||
|
||||
class _FakeGroupRideService extends GroupRideService {
|
||||
String? lastCallerUid;
|
||||
String? lastGroupRideId;
|
||||
GroupRideStatus? lastNewStatus;
|
||||
int updateRideStatusCallCount = 0;
|
||||
|
||||
@override
|
||||
Future<void> updateRideStatus({
|
||||
required String callerUid,
|
||||
required String groupRideId,
|
||||
required GroupRideStatus newStatus,
|
||||
}) async {
|
||||
lastCallerUid = callerUid;
|
||||
lastGroupRideId = groupRideId;
|
||||
lastNewStatus = newStatus;
|
||||
updateRideStatusCallCount++;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('LiveGroupRideScreen - Organizer End Ride Action Tests', () {
|
||||
final now = DateTime.now();
|
||||
|
||||
final underwayRide = GroupRide(
|
||||
id: 'ride_test_123',
|
||||
organizerUid: 'organizer_alice',
|
||||
organizerHandle: 'alice_apex',
|
||||
organizerDisplayName: 'Alice Apex',
|
||||
title: 'Sunday Morning Twisties',
|
||||
status: GroupRideStatus.underway,
|
||||
scheduledStartAt: now,
|
||||
departureAt: now,
|
||||
participantCount: 2,
|
||||
meetingPoint: const GroupRideLocation(
|
||||
latitude: 34.0,
|
||||
longitude: -84.0,
|
||||
displayName: 'Start Point',
|
||||
),
|
||||
destination: const GroupRideLocation(
|
||||
latitude: 34.5,
|
||||
longitude: -84.5,
|
||||
displayName: 'End Point',
|
||||
),
|
||||
stops: const [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
|
||||
final completedRide = underwayRide.copyWith(
|
||||
status: GroupRideStatus.completed,
|
||||
);
|
||||
|
||||
final participants = [
|
||||
GroupRideParticipant(
|
||||
userId: 'organizer_alice',
|
||||
role: GroupRideRole.lead,
|
||||
status: ParticipantStatus.accepted,
|
||||
riderHandle: 'alice_apex',
|
||||
riderDisplayName: 'Alice Apex',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
GroupRideParticipant(
|
||||
userId: 'rider_bob',
|
||||
role: GroupRideRole.participant,
|
||||
status: ParticipantStatus.accepted,
|
||||
riderHandle: 'bob_rider',
|
||||
riderDisplayName: 'Bob Rider',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
];
|
||||
|
||||
testWidgets('organizer sees "Ride Options" popup menu with "End Ride" when underway',
|
||||
(tester) async {
|
||||
final fakeService = _FakeGroupRideService();
|
||||
const user = AuthUser(
|
||||
uid: 'organizer_alice',
|
||||
email: 'alice@example.com',
|
||||
isAnonymous: false,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
currentUserProvider.overrideWithValue(user),
|
||||
groupRideServiceProvider.overrideWithValue(fakeService),
|
||||
groupRideStreamProvider('ride_test_123')
|
||||
.overrideWith((ref) => Stream.value(underwayRide)),
|
||||
groupRideParticipantsProvider('ride_test_123')
|
||||
.overrideWith((ref) => Stream.value(participants)),
|
||||
groupRideLivePresenceProvider('ride_test_123')
|
||||
.overrideWith((ref) => Stream.value([])),
|
||||
groupRideLiveSharingAllowedProvider.overrideWith((ref) => true),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: LiveGroupRideScreen(rideId: 'ride_test_123'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Ride Options menu button should be visible
|
||||
final rideOptionsFinder = find.byTooltip('Ride Options');
|
||||
expect(rideOptionsFinder, findsOneWidget);
|
||||
|
||||
// Open the popup menu
|
||||
await tester.tap(rideOptionsFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// "End Ride" menu item should be visible
|
||||
expect(find.text('End Ride'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('non-organizer participant does NOT see "Ride Options" popup menu',
|
||||
(tester) async {
|
||||
final fakeService = _FakeGroupRideService();
|
||||
const nonOrganizerUser = AuthUser(
|
||||
uid: 'rider_bob',
|
||||
email: 'bob@example.com',
|
||||
isAnonymous: false,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
currentUserProvider.overrideWithValue(nonOrganizerUser),
|
||||
groupRideServiceProvider.overrideWithValue(fakeService),
|
||||
groupRideStreamProvider('ride_test_123')
|
||||
.overrideWith((ref) => Stream.value(underwayRide)),
|
||||
groupRideParticipantsProvider('ride_test_123')
|
||||
.overrideWith((ref) => Stream.value(participants)),
|
||||
groupRideLivePresenceProvider('ride_test_123')
|
||||
.overrideWith((ref) => Stream.value([])),
|
||||
groupRideLiveSharingAllowedProvider.overrideWith((ref) => true),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: LiveGroupRideScreen(rideId: 'ride_test_123'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Non-organizer should NOT see Ride Options
|
||||
expect(find.byTooltip('Ride Options'), findsNothing);
|
||||
expect(find.text('End Ride'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('organizer does NOT see "Ride Options" popup menu when ride is completed',
|
||||
(tester) async {
|
||||
final fakeService = _FakeGroupRideService();
|
||||
const user = AuthUser(
|
||||
uid: 'organizer_alice',
|
||||
email: 'alice@example.com',
|
||||
isAnonymous: false,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
currentUserProvider.overrideWithValue(user),
|
||||
groupRideServiceProvider.overrideWithValue(fakeService),
|
||||
groupRideStreamProvider('ride_test_123')
|
||||
.overrideWith((ref) => Stream.value(completedRide)),
|
||||
groupRideParticipantsProvider('ride_test_123')
|
||||
.overrideWith((ref) => Stream.value(participants)),
|
||||
groupRideLivePresenceProvider('ride_test_123')
|
||||
.overrideWith((ref) => Stream.value([])),
|
||||
groupRideLiveSharingAllowedProvider.overrideWith((ref) => true),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: LiveGroupRideScreen(rideId: 'ride_test_123'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byTooltip('Ride Options'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('tapping End Ride shows confirmation dialog and CANCEL dismisses without state change',
|
||||
(tester) async {
|
||||
final fakeService = _FakeGroupRideService();
|
||||
const user = AuthUser(
|
||||
uid: 'organizer_alice',
|
||||
email: 'alice@example.com',
|
||||
isAnonymous: false,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
currentUserProvider.overrideWithValue(user),
|
||||
groupRideServiceProvider.overrideWithValue(fakeService),
|
||||
groupRideStreamProvider('ride_test_123')
|
||||
.overrideWith((ref) => Stream.value(underwayRide)),
|
||||
groupRideParticipantsProvider('ride_test_123')
|
||||
.overrideWith((ref) => Stream.value(participants)),
|
||||
groupRideLivePresenceProvider('ride_test_123')
|
||||
.overrideWith((ref) => Stream.value([])),
|
||||
groupRideLiveSharingAllowedProvider.overrideWith((ref) => true),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: LiveGroupRideScreen(rideId: 'ride_test_123'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap Ride Options
|
||||
await tester.tap(find.byTooltip('Ride Options'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap End Ride
|
||||
await tester.tap(find.text('End Ride'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Dialog should be displayed with expected title and copy
|
||||
expect(find.text('End this group ride?'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Ending the ride will stop live location sharing and mark the Group Ride as completed.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
// Tap CANCEL
|
||||
await tester.tap(find.text('CANCEL'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Dialog dismissed, no service call made
|
||||
expect(find.text('End this group ride?'), findsNothing);
|
||||
expect(fakeService.updateRideStatusCallCount, equals(0));
|
||||
});
|
||||
|
||||
testWidgets('tapping END RIDE in confirmation dialog triggers status update to completed',
|
||||
(tester) async {
|
||||
final fakeService = _FakeGroupRideService();
|
||||
const user = AuthUser(
|
||||
uid: 'organizer_alice',
|
||||
email: 'alice@example.com',
|
||||
isAnonymous: false,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
currentUserProvider.overrideWithValue(user),
|
||||
groupRideServiceProvider.overrideWithValue(fakeService),
|
||||
groupRideStreamProvider('ride_test_123')
|
||||
.overrideWith((ref) => Stream.value(underwayRide)),
|
||||
groupRideParticipantsProvider('ride_test_123')
|
||||
.overrideWith((ref) => Stream.value(participants)),
|
||||
groupRideLivePresenceProvider('ride_test_123')
|
||||
.overrideWith((ref) => Stream.value([])),
|
||||
groupRideLiveSharingAllowedProvider.overrideWith((ref) => true),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: LiveGroupRideScreen(rideId: 'ride_test_123'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap Ride Options
|
||||
await tester.tap(find.byTooltip('Ride Options'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap End Ride menu item
|
||||
await tester.tap(find.text('End Ride'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap END RIDE button in dialog
|
||||
await tester.tap(find.text('END RIDE'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify updateRideStatus was invoked with correct arguments
|
||||
expect(fakeService.updateRideStatusCallCount, equals(1));
|
||||
expect(fakeService.lastCallerUid, equals('organizer_alice'));
|
||||
expect(fakeService.lastGroupRideId, equals('ride_test_123'));
|
||||
expect(fakeService.lastNewStatus, equals(GroupRideStatus.completed));
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue