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/group_rides/group_ride_providers.dart';
|
||||||
import '../../services/social/social_providers.dart';
|
import '../../services/social/social_providers.dart';
|
||||||
import '../../services/auth/auth_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_models.dart';
|
||||||
|
import '../../services/routes/route_provider.dart';
|
||||||
import '../../services/routes/route_providers.dart';
|
import '../../services/routes/route_providers.dart';
|
||||||
|
import '../../services/routes/route_service.dart';
|
||||||
|
import '../routes/widgets/address_autocomplete_field.dart';
|
||||||
|
|
||||||
class CreateGroupRideScreen extends ConsumerStatefulWidget {
|
class CreateGroupRideScreen extends ConsumerStatefulWidget {
|
||||||
const CreateGroupRideScreen({super.key});
|
const CreateGroupRideScreen({super.key});
|
||||||
|
|
@ -29,21 +34,17 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
TimeOfDay _meetupTime = const TimeOfDay(hour: 9, minute: 0);
|
TimeOfDay _meetupTime = const TimeOfDay(hour: 9, minute: 0);
|
||||||
TimeOfDay _departureTime = const TimeOfDay(hour: 9, minute: 30);
|
TimeOfDay _departureTime = const TimeOfDay(hour: 9, minute: 30);
|
||||||
|
|
||||||
// Meeting point
|
// Meeting point (Start)
|
||||||
|
ResolvedPlace? _meetupPlace;
|
||||||
final _meetupNameController = TextEditingController();
|
final _meetupNameController = TextEditingController();
|
||||||
final _meetupAddressController = TextEditingController();
|
|
||||||
final _meetupNotesController = TextEditingController();
|
final _meetupNotesController = TextEditingController();
|
||||||
double _meetupLat = 34.5378;
|
|
||||||
double _meetupLng = -83.9849;
|
|
||||||
|
|
||||||
// Destination
|
// Destination
|
||||||
bool _hasDestination = true;
|
bool _hasDestination = true;
|
||||||
|
ResolvedPlace? _destinationPlace;
|
||||||
final _destNameController = TextEditingController();
|
final _destNameController = TextEditingController();
|
||||||
final _destAddressController = TextEditingController();
|
|
||||||
double _destLat = 34.8826;
|
|
||||||
double _destLng = -83.9281;
|
|
||||||
|
|
||||||
// Attached Route info
|
// Attached Route info (Provenance)
|
||||||
String? _selectedRouteId;
|
String? _selectedRouteId;
|
||||||
String? _selectedRouteTitle;
|
String? _selectedRouteTitle;
|
||||||
double? _selectedRouteDistanceMeters;
|
double? _selectedRouteDistanceMeters;
|
||||||
|
|
@ -51,6 +52,21 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
|
|
||||||
// Intermediate stops
|
// Intermediate stops
|
||||||
final List<GroupRideLocation> _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
|
// Privacy & policies
|
||||||
GroupRideVisibility _visibility = GroupRideVisibility.friendsOnly;
|
GroupRideVisibility _visibility = GroupRideVisibility.friendsOnly;
|
||||||
|
|
@ -65,10 +81,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
_titleController.dispose();
|
_titleController.dispose();
|
||||||
_descriptionController.dispose();
|
_descriptionController.dispose();
|
||||||
_meetupNameController.dispose();
|
_meetupNameController.dispose();
|
||||||
_meetupAddressController.dispose();
|
|
||||||
_meetupNotesController.dispose();
|
_meetupNotesController.dispose();
|
||||||
_destNameController.dispose();
|
_destNameController.dispose();
|
||||||
_destAddressController.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,10 +90,103 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
return DateTime(date.year, date.month, date.day, time.hour, time.minute);
|
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 {
|
Future<void> _submit() async {
|
||||||
setState(() => _errorMessage = null);
|
setState(() => _errorMessage = null);
|
||||||
if (!_formKey.currentState!.validate()) return;
|
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 startAt = _buildFullDateTime(_scheduledDate, _meetupTime);
|
||||||
final depAt = _buildFullDateTime(_scheduledDate, _departureTime);
|
final depAt = _buildFullDateTime(_scheduledDate, _departureTime);
|
||||||
|
|
||||||
|
|
@ -89,7 +196,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final organizerProfile = ref.read(myRiderProfileProvider).value;
|
final organizerProfile = ref.read(myRiderProfileProvider).valueOrNull ??
|
||||||
|
(await ref.read(myRiderProfileProvider.future));
|
||||||
if (organizerProfile == null) {
|
if (organizerProfile == null) {
|
||||||
setState(() => _errorMessage =
|
setState(() => _errorMessage =
|
||||||
'Rider profile required. Please complete your profile first.');
|
'Rider profile required. Please complete your profile first.');
|
||||||
|
|
@ -99,21 +207,24 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
setState(() => _isSaving = true);
|
setState(() => _isSaving = true);
|
||||||
try {
|
try {
|
||||||
final meetingPoint = GroupRideLocation(
|
final meetingPoint = GroupRideLocation(
|
||||||
latitude: _meetupLat,
|
latitude: _meetupPlace!.latitude,
|
||||||
longitude: _meetupLng,
|
longitude: _meetupPlace!.longitude,
|
||||||
displayName: _meetupNameController.text.trim(),
|
displayName: _meetupNameController.text.trim().isNotEmpty
|
||||||
address: _meetupAddressController.text.trim(),
|
? _meetupNameController.text.trim()
|
||||||
|
: _meetupPlace!.displayName,
|
||||||
|
address: _meetupPlace!.formattedAddress ?? '',
|
||||||
locationType: GroupRideLocationType.meetup,
|
locationType: GroupRideLocationType.meetup,
|
||||||
notes: _meetupNotesController.text.trim(),
|
notes: _meetupNotesController.text.trim(),
|
||||||
);
|
);
|
||||||
|
|
||||||
final destination = _hasDestination &&
|
final destination = _hasDestination && _destinationPlace != null
|
||||||
_destNameController.text.trim().isNotEmpty
|
|
||||||
? GroupRideLocation(
|
? GroupRideLocation(
|
||||||
latitude: _destLat,
|
latitude: _destinationPlace!.latitude,
|
||||||
longitude: _destLng,
|
longitude: _destinationPlace!.longitude,
|
||||||
displayName: _destNameController.text.trim(),
|
displayName: _destNameController.text.trim().isNotEmpty
|
||||||
address: _destAddressController.text.trim(),
|
? _destNameController.text.trim()
|
||||||
|
: _destinationPlace!.displayName,
|
||||||
|
address: _destinationPlace!.formattedAddress ?? '',
|
||||||
locationType: GroupRideLocationType.destination,
|
locationType: GroupRideLocationType.destination,
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -168,9 +279,14 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
|
|
||||||
if (route.stops.isNotEmpty) {
|
if (route.stops.isNotEmpty) {
|
||||||
final origin = route.stops.first;
|
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;
|
_meetupNameController.text = origin.displayName;
|
||||||
_meetupLat = origin.latitude;
|
|
||||||
_meetupLng = origin.longitude;
|
|
||||||
if (origin.notes.isNotEmpty) {
|
if (origin.notes.isNotEmpty) {
|
||||||
_meetupNotesController.text = origin.notes;
|
_meetupNotesController.text = origin.notes;
|
||||||
}
|
}
|
||||||
|
|
@ -178,12 +294,14 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
if (route.stops.length > 1) {
|
if (route.stops.length > 1) {
|
||||||
final dest = route.stops.last;
|
final dest = route.stops.last;
|
||||||
_hasDestination = true;
|
_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;
|
_destNameController.text = dest.displayName;
|
||||||
_destLat = dest.latitude;
|
|
||||||
_destLng = dest.longitude;
|
|
||||||
if (dest.notes.isNotEmpty) {
|
|
||||||
_destAddressController.text = dest.notes;
|
|
||||||
}
|
|
||||||
|
|
||||||
_stops.clear();
|
_stops.clear();
|
||||||
if (route.stops.length > 2) {
|
if (route.stops.length > 2) {
|
||||||
|
|
@ -203,6 +321,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
_triggerRouteCalculation();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showSelectRouteSheet(MotorSignalThemeColors colors) {
|
void _showSelectRouteSheet(MotorSignalThemeColors colors) {
|
||||||
|
|
@ -227,7 +346,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const Text(
|
||||||
'IMPORT SAVED ROUTE',
|
'USE EXISTING ROUTE',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|
@ -307,10 +426,31 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _addStopDialog(MotorSignalThemeColors colors) {
|
void _addStopDialog(MotorSignalThemeColors colors, [int? editIndex]) {
|
||||||
final nameCtrl = TextEditingController();
|
if (editIndex == null && !_canAddStop) {
|
||||||
final notesCtrl = TextEditingController();
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
var stopType = GroupRideLocationType.fuel;
|
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(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -321,17 +461,37 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
side: BorderSide(color: colors.outlineVariant),
|
side: BorderSide(color: colors.outlineVariant),
|
||||||
),
|
),
|
||||||
title: const Text('Add Intermediate Stop',
|
title: Text(
|
||||||
style: TextStyle(color: Colors.white, fontSize: 16)),
|
existing != null ? 'Edit Intermediate Stop' : 'Add Intermediate Stop',
|
||||||
content: Column(
|
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
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(
|
TextField(
|
||||||
controller: nameCtrl,
|
controller: nameCtrl,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: const TextStyle(color: Colors.white),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Stop Name (e.g. Shell Fuel / Overlook)',
|
labelText: 'Custom Stop Label (Optional)',
|
||||||
labelStyle: const TextStyle(color: Colors.white70),
|
labelStyle: const TextStyle(color: Colors.white70),
|
||||||
|
hintText: 'e.g. Shell Station / Scenic Overlook',
|
||||||
|
hintStyle: const TextStyle(color: Colors.white24),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: colors.surfaceContainer,
|
fillColor: colors.surfaceContainer,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
|
|
@ -339,7 +499,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 12),
|
||||||
DropdownButtonFormField<GroupRideLocationType>(
|
DropdownButtonFormField<GroupRideLocationType>(
|
||||||
initialValue: stopType,
|
initialValue: stopType,
|
||||||
dropdownColor: colors.surfaceContainerHigh,
|
dropdownColor: colors.surfaceContainerHigh,
|
||||||
|
|
@ -368,13 +528,15 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
if (val != null) setDialogState(() => stopType = val);
|
if (val != null) setDialogState(() => stopType = val);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
controller: notesCtrl,
|
controller: notesCtrl,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: const TextStyle(color: Colors.white),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Notes (optional)',
|
labelText: 'Notes (optional)',
|
||||||
labelStyle: const TextStyle(color: Colors.white70),
|
labelStyle: const TextStyle(color: Colors.white70),
|
||||||
|
hintText: 'e.g. Park near pumps, 15 min rest',
|
||||||
|
hintStyle: const TextStyle(color: Colors.white24),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: colors.surfaceContainer,
|
fillColor: colors.surfaceContainer,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
|
|
@ -384,11 +546,11 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx),
|
onPressed: () => Navigator.pop(ctx),
|
||||||
child: const Text('CANCEL',
|
child: const Text('CANCEL', style: TextStyle(color: Colors.white60)),
|
||||||
style: TextStyle(color: Colors.white60)),
|
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
|
|
@ -396,20 +558,35 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
foregroundColor: Colors.black,
|
foregroundColor: Colors.black,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (nameCtrl.text.trim().isNotEmpty) {
|
if (resolved == null) {
|
||||||
setState(() {
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||||
_stops.add(GroupRideLocation(
|
const SnackBar(
|
||||||
latitude: 0,
|
content: Text('Please select an address from search suggestions.'),
|
||||||
longitude: 0,
|
),
|
||||||
displayName: nameCtrl.text.trim(),
|
);
|
||||||
|
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,
|
locationType: stopType,
|
||||||
notes: notesCtrl.text.trim(),
|
notes: notesCtrl.text.trim(),
|
||||||
));
|
);
|
||||||
|
setState(() {
|
||||||
|
if (editIndex != null) {
|
||||||
|
_stops[editIndex] = loc;
|
||||||
|
} else {
|
||||||
|
_stops.add(loc);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
Navigator.pop(ctx);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colors = context.motorColors;
|
final colors = context.motorColors;
|
||||||
|
ref.watch(myRiderProfileProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colors.surfaceContainer,
|
backgroundColor: colors.surfaceContainer,
|
||||||
|
|
@ -435,11 +613,6 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.alt_route, color: colors.cyberCyan),
|
|
||||||
tooltip: 'Import Route',
|
|
||||||
onPressed: () => _showSelectRouteSheet(colors),
|
|
||||||
),
|
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: _isSaving ? null : _submit,
|
onPressed: _isSaving ? null : _submit,
|
||||||
child: _isSaving
|
child: _isSaving
|
||||||
|
|
@ -461,7 +634,9 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: ResponsiveLayout(
|
body: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: ResponsiveLayout(
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final isWide = snapshot.sizeClass == WindowSizeClass.expanded ||
|
final isWide = snapshot.sizeClass == WindowSizeClass.expanded ||
|
||||||
(snapshot.isLandscape && snapshot.width >= 720);
|
(snapshot.isLandscape && snapshot.width >= 720);
|
||||||
|
|
@ -472,6 +647,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
return _buildCompactForm(context, colors, snapshot);
|
return _buildCompactForm(context, colors, snapshot);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -480,9 +656,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
MotorSignalThemeColors colors,
|
MotorSignalThemeColors colors,
|
||||||
ResponsiveSnapshot snapshot,
|
ResponsiveSnapshot snapshot,
|
||||||
) {
|
) {
|
||||||
return Form(
|
return SingleChildScrollView(
|
||||||
key: _formKey,
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
|
@ -506,7 +680,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 24),
|
const SizedBox(width: 24),
|
||||||
// Right Column: Meeting Point, Destination & Route
|
// Right Column: Meeting Point, Destination & Stops, Preview, Submit
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 5,
|
flex: 5,
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -516,6 +690,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_buildDestinationAndStopsSection(colors),
|
_buildDestinationAndStopsSection(colors),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
_buildMapPreviewSection(colors, snapshot),
|
||||||
|
const SizedBox(height: 24),
|
||||||
_buildSubmitButton(colors),
|
_buildSubmitButton(colors),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -525,7 +701,6 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -534,9 +709,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
MotorSignalThemeColors colors,
|
MotorSignalThemeColors colors,
|
||||||
ResponsiveSnapshot snapshot,
|
ResponsiveSnapshot snapshot,
|
||||||
) {
|
) {
|
||||||
return Form(
|
return ListView(
|
||||||
key: _formKey,
|
|
||||||
child: ListView(
|
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
children: [
|
children: [
|
||||||
if (_errorMessage != null) _buildErrorBanner(colors),
|
if (_errorMessage != null) _buildErrorBanner(colors),
|
||||||
|
|
@ -548,12 +721,13 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_buildDestinationAndStopsSection(colors),
|
_buildDestinationAndStopsSection(colors),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
_buildMapPreviewSection(colors, snapshot),
|
||||||
|
const SizedBox(height: 24),
|
||||||
_buildPrivacySection(colors),
|
_buildPrivacySection(colors),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
_buildSubmitButton(colors),
|
_buildSubmitButton(colors),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -732,6 +906,22 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
children: [
|
children: [
|
||||||
_buildSectionHeader('3. MEETING LOCATION', colors),
|
_buildSectionHeader('3. MEETING LOCATION', colors),
|
||||||
const SizedBox(height: 10),
|
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(
|
TextFormField(
|
||||||
controller: _meetupNameController,
|
controller: _meetupNameController,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: const TextStyle(color: Colors.white),
|
||||||
|
|
@ -748,16 +938,6 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
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(
|
TextFormField(
|
||||||
controller: _meetupNotesController,
|
controller: _meetupNotesController,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: const TextStyle(color: Colors.white),
|
||||||
|
|
@ -772,6 +952,9 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDestinationAndStopsSection(MotorSignalThemeColors colors) {
|
Widget _buildDestinationAndStopsSection(MotorSignalThemeColors colors) {
|
||||||
|
final canAdd = _canAddStop;
|
||||||
|
final totalStops = _totalPlannedStops;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -783,10 +966,31 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
title: const Text('Specific Destination',
|
title: const Text('Specific Destination',
|
||||||
style: TextStyle(color: Colors.white)),
|
style: TextStyle(color: Colors.white)),
|
||||||
value: _hasDestination,
|
value: _hasDestination,
|
||||||
onChanged: (val) => setState(() => _hasDestination = val),
|
onChanged: (val) {
|
||||||
|
setState(() {
|
||||||
|
_hasDestination = val;
|
||||||
|
});
|
||||||
|
_triggerRouteCalculation();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
if (_hasDestination) ...[
|
if (_hasDestination) ...[
|
||||||
const SizedBox(height: 8),
|
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(
|
TextFormField(
|
||||||
controller: _destNameController,
|
controller: _destNameController,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: const TextStyle(color: Colors.white),
|
||||||
|
|
@ -796,19 +1000,9 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
colors,
|
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),
|
const SizedBox(height: 16),
|
||||||
// Route Pre-fill / Attachment
|
// USE EXISTING ROUTE Card
|
||||||
Container(
|
Container(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
|
|
@ -828,7 +1022,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
Text(
|
Text(
|
||||||
_selectedRouteTitle != null
|
_selectedRouteTitle != null
|
||||||
? 'Route: $_selectedRouteTitle'
|
? 'Route: $_selectedRouteTitle'
|
||||||
: 'Plan with Saved Route',
|
: 'Use Existing Stage 8 Route',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|
@ -839,7 +1033,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
Text(
|
Text(
|
||||||
_selectedRouteTitle != null
|
_selectedRouteTitle != null
|
||||||
? '$_selectedRouteStopCount stops • ${(_selectedRouteDistanceMeters != null ? (_selectedRouteDistanceMeters! / 1609.34).toStringAsFixed(1) : 0)} mi'
|
? '$_selectedRouteStopCount stops • ${(_selectedRouteDistanceMeters != null ? (_selectedRouteDistanceMeters! / 1609.34).toStringAsFixed(1) : 0)} mi'
|
||||||
: 'Optional: import stops & coordinates',
|
: 'Import saved route stops & coordinates',
|
||||||
style:
|
style:
|
||||||
const TextStyle(color: Colors.white54, fontSize: 11),
|
const TextStyle(color: Colors.white54, fontSize: 11),
|
||||||
),
|
),
|
||||||
|
|
@ -861,19 +1055,19 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
OutlinedButton(
|
ElevatedButton.icon(
|
||||||
onPressed: () => _showSelectRouteSheet(colors),
|
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),
|
side: BorderSide(color: colors.cyberCyan),
|
||||||
padding:
|
padding:
|
||||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
minimumSize: Size.zero,
|
minimumSize: Size.zero,
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
),
|
textStyle: const TextStyle(
|
||||||
child: Text(
|
|
||||||
'IMPORT',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colors.cyberCyan,
|
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
|
|
@ -885,22 +1079,29 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
const Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Intermediate Stops',
|
'Intermediate Stops ($totalStops/$_maxTechnicalStops max)',
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 13,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: () => _addStopDialog(colors),
|
onPressed: canAdd ? () => _addStopDialog(colors) : null,
|
||||||
icon: Icon(Icons.add_location_alt,
|
icon: Icon(Icons.add_location_alt,
|
||||||
size: 16, color: colors.cyberCyan),
|
size: 16,
|
||||||
label: Text('ADD STOP',
|
color: canAdd ? colors.cyberCyan : Colors.white24),
|
||||||
style: TextStyle(color: colors.cyberCyan)),
|
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);
|
final item = _stops.removeAt(oldIndex);
|
||||||
_stops.insert(newIndex, item);
|
_stops.insert(newIndex, item);
|
||||||
});
|
});
|
||||||
|
_triggerRouteCalculation();
|
||||||
},
|
},
|
||||||
children: [
|
children: [
|
||||||
for (int i = 0; i < _stops.length; i++)
|
for (int i = 0; i < _stops.length; i++)
|
||||||
ListTile(
|
ListTile(
|
||||||
key: ValueKey('stop_$i'),
|
key: ValueKey('stop_$i'),
|
||||||
tileColor: colors.surfaceContainerHigh,
|
tileColor: colors.surfaceContainerHigh,
|
||||||
|
onTap: () => _addStopDialog(colors, i),
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
radius: 12,
|
radius: 12,
|
||||||
backgroundColor: colors.cyberCyan.withValues(alpha: 0.2),
|
backgroundColor: colors.cyberCyan.withValues(alpha: 0.2),
|
||||||
|
|
@ -945,14 +1148,19 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
const TextStyle(color: Colors.white, fontSize: 13),
|
const TextStyle(color: Colors.white, fontSize: 13),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
_stops[i].locationType.label,
|
'${_stops[i].locationType.label}${_stops[i].address.isNotEmpty ? ' • ${_stops[i].address}' : ''}',
|
||||||
style:
|
style:
|
||||||
const TextStyle(color: Colors.white54, fontSize: 11),
|
const TextStyle(color: Colors.white54, fontSize: 11),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
icon: const Icon(Icons.delete_outline,
|
icon: const Icon(Icons.delete_outline,
|
||||||
size: 18, color: Colors.white38),
|
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) {
|
Widget _buildPrivacySection(MotorSignalThemeColors colors) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
|
||||||
|
|
@ -476,14 +476,35 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
if (isOrganizer)
|
if (isOrganizer && ride.status == GroupRideStatus.underway)
|
||||||
IconButton(
|
PopupMenuButton<String>(
|
||||||
icon: Icon(
|
icon: const Icon(Icons.more_vert, color: Colors.white70),
|
||||||
Icons.stop_circle_outlined,
|
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,
|
color: colors.raceRed,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
tooltip: 'End Ride',
|
),
|
||||||
onPressed: () => _confirmEndRide(context, ride, currentUid, colors),
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(Icons.people_outline, color: colors.cyberCyan),
|
icon: Icon(Icons.people_outline, color: colors.cyberCyan),
|
||||||
|
|
@ -1441,11 +1462,11 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
backgroundColor: colors.surfaceContainerHigh,
|
backgroundColor: colors.surfaceContainerHigh,
|
||||||
title: const Text(
|
title: const Text(
|
||||||
'End Group Ride?',
|
'End this group ride?',
|
||||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
content: const Text(
|
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),
|
style: TextStyle(color: Colors.white70),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
|
@ -1469,9 +1490,6 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
||||||
groupRideId: ride.id,
|
groupRideId: ride.id,
|
||||||
newStatus: GroupRideStatus.completed,
|
newStatus: GroupRideStatus.completed,
|
||||||
);
|
);
|
||||||
if (context.mounted) {
|
|
||||||
context.pop();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
child: const Text('END RIDE'),
|
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_models.dart';
|
||||||
import '../../services/routes/route_providers.dart';
|
import '../../services/routes/route_providers.dart';
|
||||||
import '../../services/routes/route_service.dart';
|
import '../../services/routes/route_service.dart';
|
||||||
|
import 'widgets/address_autocomplete_field.dart';
|
||||||
|
|
||||||
class RoutePlannerScreen extends ConsumerStatefulWidget {
|
class RoutePlannerScreen extends ConsumerStatefulWidget {
|
||||||
final String? initialRouteId;
|
final String? initialRouteId;
|
||||||
|
|
@ -162,6 +163,34 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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(
|
TextField(
|
||||||
controller: nameCtrl,
|
controller: nameCtrl,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: const TextStyle(color: Colors.white),
|
||||||
|
|
@ -170,6 +199,20 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
labelStyle: const TextStyle(color: Colors.white60),
|
labelStyle: const TextStyle(color: Colors.white60),
|
||||||
hintText: 'e.g. Skyline Summit, Fuel Stop',
|
hintText: 'e.g. Skyline Summit, Fuel Stop',
|
||||||
hintStyle: const TextStyle(color: Colors.white24),
|
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(
|
enabledBorder: UnderlineInputBorder(
|
||||||
borderSide: BorderSide(color: colors.outlineVariant),
|
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 {
|
Future<void> _saveRoute() async {
|
||||||
final title = _titleController.text.trim();
|
final title = _titleController.text.trim();
|
||||||
if (title.length < 3 || title.length > 80) {
|
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/map/map_provider_interface.dart';
|
||||||
import 'package:motorcycle_dash/services/routes/route_models.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_provider.dart';
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
class FakeRouteProvider implements RouteProvider {
|
class FakeRouteProvider implements RouteProvider {
|
||||||
final bool shouldFail;
|
final bool shouldFail;
|
||||||
final bool shouldTimeout;
|
final bool shouldTimeout;
|
||||||
|
final List<PlaceSearchResult>? customSearchResults;
|
||||||
|
final ResolvedPlace? customResolvedPlace;
|
||||||
|
final Future<void> Function(int count)? onCalculate;
|
||||||
|
int _calcCount = 0;
|
||||||
|
|
||||||
|
List<RouteStop>? lastCapturedStops;
|
||||||
|
|
||||||
FakeRouteProvider({
|
FakeRouteProvider({
|
||||||
this.shouldFail = false,
|
this.shouldFail = false,
|
||||||
this.shouldTimeout = false,
|
this.shouldTimeout = false,
|
||||||
|
this.customSearchResults,
|
||||||
|
this.customResolvedPlace,
|
||||||
|
this.onCalculate,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -21,6 +31,12 @@ class FakeRouteProvider implements RouteProvider {
|
||||||
bool avoidTolls = false,
|
bool avoidTolls = false,
|
||||||
bool avoidFerries = false,
|
bool avoidFerries = false,
|
||||||
}) async {
|
}) async {
|
||||||
|
lastCapturedStops = stops;
|
||||||
|
|
||||||
|
if (onCalculate != null) {
|
||||||
|
await onCalculate!(_calcCount++);
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldTimeout) {
|
if (shouldTimeout) {
|
||||||
await Future.delayed(const Duration(seconds: 2));
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
throw RouteProviderException('providerTimeout', 'Fake timeout');
|
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
|
@override
|
||||||
Future<List<PlaceSearchResult>> searchPlaces(
|
Future<List<PlaceSearchResult>> searchPlaces(
|
||||||
String query, {
|
String query, {
|
||||||
AppLatLng? nearLocation,
|
AppLatLng? nearLocation,
|
||||||
}) async {
|
}) 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 [];
|
if (query.length < 2) return [];
|
||||||
|
|
||||||
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