diff --git a/lib/features/group_rides/create_group_ride_screen.dart b/lib/features/group_rides/create_group_ride_screen.dart index 783ffed..eff2c6f 100644 --- a/lib/features/group_rides/create_group_ride_screen.dart +++ b/lib/features/group_rides/create_group_ride_screen.dart @@ -8,8 +8,13 @@ import '../../services/group_rides/group_ride_models.dart'; import '../../services/group_rides/group_ride_providers.dart'; import '../../services/social/social_providers.dart'; import '../../services/auth/auth_providers.dart'; +import '../../services/map/map_provider_interface.dart'; +import '../../services/map/unified_map_view.dart'; import '../../services/routes/route_models.dart'; +import '../../services/routes/route_provider.dart'; import '../../services/routes/route_providers.dart'; +import '../../services/routes/route_service.dart'; +import '../routes/widgets/address_autocomplete_field.dart'; class CreateGroupRideScreen extends ConsumerStatefulWidget { const CreateGroupRideScreen({super.key}); @@ -29,21 +34,17 @@ class _CreateGroupRideScreenState extends ConsumerState { TimeOfDay _meetupTime = const TimeOfDay(hour: 9, minute: 0); TimeOfDay _departureTime = const TimeOfDay(hour: 9, minute: 30); - // Meeting point + // Meeting point (Start) + ResolvedPlace? _meetupPlace; final _meetupNameController = TextEditingController(); - final _meetupAddressController = TextEditingController(); final _meetupNotesController = TextEditingController(); - double _meetupLat = 34.5378; - double _meetupLng = -83.9849; // Destination bool _hasDestination = true; + ResolvedPlace? _destinationPlace; final _destNameController = TextEditingController(); - final _destAddressController = TextEditingController(); - double _destLat = 34.8826; - double _destLng = -83.9281; - // Attached Route info + // Attached Route info (Provenance) String? _selectedRouteId; String? _selectedRouteTitle; double? _selectedRouteDistanceMeters; @@ -51,6 +52,21 @@ class _CreateGroupRideScreenState extends ConsumerState { // Intermediate stops final List _stops = []; + static const int _maxTechnicalStops = RouteService.maxTechnicalStops; // 25 + + int get _totalPlannedStops => + 1 + (_hasDestination && _destinationPlace != null ? 1 : 0) + _stops.length; + + bool get _canAddStop => + 1 + (_hasDestination ? 1 : 0) + _stops.length < _maxTechnicalStops; + + // Runtime Route Calculation & Preview + RouteCalculationResult? _calculatedRouteResult; + bool _isCalculatingRoute = false; + String? _routeCalculationError; + int _calculationSequence = 0; + AppLatLng? _mapCenter; + final double _mapZoom = 11.0; // Privacy & policies GroupRideVisibility _visibility = GroupRideVisibility.friendsOnly; @@ -65,10 +81,8 @@ class _CreateGroupRideScreenState extends ConsumerState { _titleController.dispose(); _descriptionController.dispose(); _meetupNameController.dispose(); - _meetupAddressController.dispose(); _meetupNotesController.dispose(); _destNameController.dispose(); - _destAddressController.dispose(); super.dispose(); } @@ -76,10 +90,103 @@ class _CreateGroupRideScreenState extends ConsumerState { 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( + 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 _submit() async { setState(() => _errorMessage = null); if (!_formKey.currentState!.validate()) return; + if (_meetupPlace == null) { + setState(() => _errorMessage = + 'Please search and select a valid Meetup Location from suggestions.'); + return; + } + + if (_hasDestination && _destinationPlace == null) { + setState(() => _errorMessage = + 'Please search and select a valid Destination from suggestions.'); + return; + } + final startAt = _buildFullDateTime(_scheduledDate, _meetupTime); final depAt = _buildFullDateTime(_scheduledDate, _departureTime); @@ -89,7 +196,8 @@ class _CreateGroupRideScreenState extends ConsumerState { return; } - final organizerProfile = ref.read(myRiderProfileProvider).value; + final organizerProfile = ref.read(myRiderProfileProvider).valueOrNull ?? + (await ref.read(myRiderProfileProvider.future)); if (organizerProfile == null) { setState(() => _errorMessage = 'Rider profile required. Please complete your profile first.'); @@ -99,21 +207,24 @@ class _CreateGroupRideScreenState extends ConsumerState { setState(() => _isSaving = true); try { final meetingPoint = GroupRideLocation( - latitude: _meetupLat, - longitude: _meetupLng, - displayName: _meetupNameController.text.trim(), - address: _meetupAddressController.text.trim(), + latitude: _meetupPlace!.latitude, + longitude: _meetupPlace!.longitude, + displayName: _meetupNameController.text.trim().isNotEmpty + ? _meetupNameController.text.trim() + : _meetupPlace!.displayName, + address: _meetupPlace!.formattedAddress ?? '', locationType: GroupRideLocationType.meetup, notes: _meetupNotesController.text.trim(), ); - final destination = _hasDestination && - _destNameController.text.trim().isNotEmpty + final destination = _hasDestination && _destinationPlace != null ? GroupRideLocation( - latitude: _destLat, - longitude: _destLng, - displayName: _destNameController.text.trim(), - address: _destAddressController.text.trim(), + latitude: _destinationPlace!.latitude, + longitude: _destinationPlace!.longitude, + displayName: _destNameController.text.trim().isNotEmpty + ? _destNameController.text.trim() + : _destinationPlace!.displayName, + address: _destinationPlace!.formattedAddress ?? '', locationType: GroupRideLocationType.destination, ) : null; @@ -168,9 +279,14 @@ class _CreateGroupRideScreenState extends ConsumerState { if (route.stops.isNotEmpty) { final origin = route.stops.first; + _meetupPlace = ResolvedPlace( + providerId: origin.id, + displayName: origin.displayName, + formattedAddress: origin.notes.isNotEmpty ? origin.notes : origin.displayName, + latitude: origin.latitude, + longitude: origin.longitude, + ); _meetupNameController.text = origin.displayName; - _meetupLat = origin.latitude; - _meetupLng = origin.longitude; if (origin.notes.isNotEmpty) { _meetupNotesController.text = origin.notes; } @@ -178,12 +294,14 @@ class _CreateGroupRideScreenState extends ConsumerState { if (route.stops.length > 1) { final dest = route.stops.last; _hasDestination = true; + _destinationPlace = ResolvedPlace( + providerId: dest.id, + displayName: dest.displayName, + formattedAddress: dest.notes.isNotEmpty ? dest.notes : dest.displayName, + latitude: dest.latitude, + longitude: dest.longitude, + ); _destNameController.text = dest.displayName; - _destLat = dest.latitude; - _destLng = dest.longitude; - if (dest.notes.isNotEmpty) { - _destAddressController.text = dest.notes; - } _stops.clear(); if (route.stops.length > 2) { @@ -203,6 +321,7 @@ class _CreateGroupRideScreenState extends ConsumerState { } } }); + _triggerRouteCalculation(); } void _showSelectRouteSheet(MotorSignalThemeColors colors) { @@ -227,7 +346,7 @@ class _CreateGroupRideScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( - 'IMPORT SAVED ROUTE', + 'USE EXISTING ROUTE', style: TextStyle( color: Colors.white, fontWeight: FontWeight.bold, @@ -307,10 +426,31 @@ class _CreateGroupRideScreenState extends ConsumerState { ); } - void _addStopDialog(MotorSignalThemeColors colors) { - final nameCtrl = TextEditingController(); - final notesCtrl = TextEditingController(); - var stopType = GroupRideLocationType.fuel; + void _addStopDialog(MotorSignalThemeColors colors, [int? editIndex]) { + if (editIndex == null && !_canAddStop) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Maximum of 25 total stops reached.'), + backgroundColor: colors.surfaceContainerHighest, + ), + ); + return; + } + + final existing = editIndex != null ? _stops[editIndex] : null; + ResolvedPlace? resolved = existing != null + ? ResolvedPlace( + providerId: 'stop_$editIndex', + displayName: existing.displayName, + formattedAddress: existing.address, + latitude: existing.latitude, + longitude: existing.longitude, + ) + : null; + + final nameCtrl = TextEditingController(text: existing?.displayName ?? ''); + final notesCtrl = TextEditingController(text: existing?.notes ?? ''); + var stopType = existing?.locationType ?? GroupRideLocationType.fuel; showDialog( context: context, @@ -321,74 +461,96 @@ class _CreateGroupRideScreenState extends ConsumerState { borderRadius: BorderRadius.circular(16), side: BorderSide(color: colors.outlineVariant), ), - title: const Text('Add Intermediate Stop', - style: TextStyle(color: Colors.white, fontSize: 16)), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: nameCtrl, - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - labelText: 'Stop Name (e.g. Shell Fuel / Overlook)', - labelStyle: const TextStyle(color: Colors.white70), - filled: true, - fillColor: colors.surfaceContainer, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), + title: Text( + existing != null ? 'Edit Intermediate Stop' : 'Add Intermediate Stop', + style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold), + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AddressAutocompleteField( + labelText: 'Stop Address / Location *', + hintText: 'Search address or landmark...', + initialPlace: resolved, + onPlaceResolved: (place) { + setDialogState(() { + resolved = place; + if (place != null && nameCtrl.text.trim().isEmpty) { + nameCtrl.text = place.displayName; + } + }); + }, + ), + const SizedBox(height: 12), + TextField( + controller: nameCtrl, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + labelText: 'Custom Stop Label (Optional)', + labelStyle: const TextStyle(color: Colors.white70), + hintText: 'e.g. Shell Station / Scenic Overlook', + hintStyle: const TextStyle(color: Colors.white24), + filled: true, + fillColor: colors.surfaceContainer, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), ), ), - ), - const SizedBox(height: 10), - DropdownButtonFormField( - initialValue: stopType, - dropdownColor: colors.surfaceContainerHigh, - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - labelText: 'Stop Type', - filled: true, - fillColor: colors.surfaceContainer, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: stopType, + dropdownColor: colors.surfaceContainerHigh, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + labelText: 'Stop Type', + filled: true, + fillColor: colors.surfaceContainer, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + items: [ + GroupRideLocationType.fuel, + GroupRideLocationType.rest, + GroupRideLocationType.food, + GroupRideLocationType.scenic, + GroupRideLocationType.waypoint, + ].map((type) { + return DropdownMenuItem( + value: type, + child: Text(type.label), + ); + }).toList(), + onChanged: (val) { + if (val != null) setDialogState(() => stopType = val); + }, + ), + const SizedBox(height: 12), + TextField( + controller: notesCtrl, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + labelText: 'Notes (optional)', + labelStyle: const TextStyle(color: Colors.white70), + hintText: 'e.g. Park near pumps, 15 min rest', + hintStyle: const TextStyle(color: Colors.white24), + filled: true, + fillColor: colors.surfaceContainer, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), ), ), - items: [ - GroupRideLocationType.fuel, - GroupRideLocationType.rest, - GroupRideLocationType.food, - GroupRideLocationType.scenic, - GroupRideLocationType.waypoint, - ].map((type) { - return DropdownMenuItem( - value: type, - child: Text(type.label), - ); - }).toList(), - onChanged: (val) { - if (val != null) setDialogState(() => stopType = val); - }, - ), - const SizedBox(height: 10), - TextField( - controller: notesCtrl, - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - labelText: 'Notes (optional)', - labelStyle: const TextStyle(color: Colors.white70), - filled: true, - fillColor: colors.surfaceContainer, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ], + ], + ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text('CANCEL', - style: TextStyle(color: Colors.white60)), + child: const Text('CANCEL', style: TextStyle(color: Colors.white60)), ), ElevatedButton( style: ElevatedButton.styleFrom( @@ -396,20 +558,35 @@ class _CreateGroupRideScreenState extends ConsumerState { foregroundColor: Colors.black, ), onPressed: () { - if (nameCtrl.text.trim().isNotEmpty) { - setState(() { - _stops.add(GroupRideLocation( - latitude: 0, - longitude: 0, - displayName: nameCtrl.text.trim(), - locationType: stopType, - notes: notesCtrl.text.trim(), - )); - }); - Navigator.pop(ctx); + if (resolved == null) { + ScaffoldMessenger.of(ctx).showSnackBar( + const SnackBar( + content: Text('Please select an address from search suggestions.'), + ), + ); + return; } + final loc = GroupRideLocation( + latitude: resolved!.latitude, + longitude: resolved!.longitude, + displayName: nameCtrl.text.trim().isNotEmpty + ? nameCtrl.text.trim() + : resolved!.displayName, + address: resolved!.formattedAddress ?? '', + locationType: stopType, + notes: notesCtrl.text.trim(), + ); + setState(() { + if (editIndex != null) { + _stops[editIndex] = loc; + } else { + _stops.add(loc); + } + }); + Navigator.pop(ctx); + _triggerRouteCalculation(); }, - child: const Text('ADD STOP'), + child: Text(existing != null ? 'SAVE STOP' : 'ADD STOP'), ), ], ), @@ -420,6 +597,7 @@ class _CreateGroupRideScreenState extends ConsumerState { @override Widget build(BuildContext context) { final colors = context.motorColors; + ref.watch(myRiderProfileProvider); return Scaffold( backgroundColor: colors.surfaceContainer, @@ -435,11 +613,6 @@ class _CreateGroupRideScreenState extends ConsumerState { ), ), actions: [ - IconButton( - icon: Icon(Icons.alt_route, color: colors.cyberCyan), - tooltip: 'Import Route', - onPressed: () => _showSelectRouteSheet(colors), - ), TextButton( onPressed: _isSaving ? null : _submit, child: _isSaving @@ -461,16 +634,19 @@ class _CreateGroupRideScreenState extends ConsumerState { ), ], ), - body: ResponsiveLayout( - builder: (context, snapshot) { - final isWide = snapshot.sizeClass == WindowSizeClass.expanded || - (snapshot.isLandscape && snapshot.width >= 720); + body: Form( + key: _formKey, + child: ResponsiveLayout( + builder: (context, snapshot) { + final isWide = snapshot.sizeClass == WindowSizeClass.expanded || + (snapshot.isLandscape && snapshot.width >= 720); - if (isWide) { - return _buildWideForm(context, colors, snapshot); - } - return _buildCompactForm(context, colors, snapshot); - }, + if (isWide) { + return _buildWideForm(context, colors, snapshot); + } + return _buildCompactForm(context, colors, snapshot); + }, + ), ), ); } @@ -480,51 +656,50 @@ class _CreateGroupRideScreenState extends ConsumerState { MotorSignalThemeColors colors, ResponsiveSnapshot snapshot, ) { - return Form( - key: _formKey, - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (_errorMessage != null) _buildErrorBanner(colors), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Left Column: Details, Schedule, Privacy - Expanded( - flex: 5, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildRideDetailsSection(colors), - const SizedBox(height: 24), - _buildScheduleSection(colors), - const SizedBox(height: 24), - _buildPrivacySection(colors), - ], - ), + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_errorMessage != null) _buildErrorBanner(colors), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Left Column: Details, Schedule, Privacy + Expanded( + flex: 5, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildRideDetailsSection(colors), + const SizedBox(height: 24), + _buildScheduleSection(colors), + const SizedBox(height: 24), + _buildPrivacySection(colors), + ], ), - const SizedBox(width: 24), - // Right Column: Meeting Point, Destination & Route - Expanded( - flex: 5, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildMeetingPointSection(colors), - const SizedBox(height: 24), - _buildDestinationAndStopsSection(colors), - const SizedBox(height: 24), - _buildSubmitButton(colors), - ], - ), + ), + const SizedBox(width: 24), + // Right Column: Meeting Point, Destination & Stops, Preview, Submit + Expanded( + flex: 5, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildMeetingPointSection(colors), + const SizedBox(height: 24), + _buildDestinationAndStopsSection(colors), + const SizedBox(height: 24), + _buildMapPreviewSection(colors, snapshot), + const SizedBox(height: 24), + _buildSubmitButton(colors), + ], ), - ], - ), - const SizedBox(height: 32), - ], - ), + ), + ], + ), + const SizedBox(height: 32), + ], ), ); } @@ -534,26 +709,25 @@ class _CreateGroupRideScreenState extends ConsumerState { MotorSignalThemeColors colors, ResponsiveSnapshot snapshot, ) { - return Form( - key: _formKey, - child: ListView( - padding: const EdgeInsets.all(20), - children: [ - if (_errorMessage != null) _buildErrorBanner(colors), - _buildRideDetailsSection(colors), - const SizedBox(height: 24), - _buildScheduleSection(colors), - const SizedBox(height: 24), - _buildMeetingPointSection(colors), - const SizedBox(height: 24), - _buildDestinationAndStopsSection(colors), - const SizedBox(height: 24), - _buildPrivacySection(colors), - const SizedBox(height: 32), - _buildSubmitButton(colors), - const SizedBox(height: 32), - ], - ), + return ListView( + padding: const EdgeInsets.all(20), + children: [ + if (_errorMessage != null) _buildErrorBanner(colors), + _buildRideDetailsSection(colors), + const SizedBox(height: 24), + _buildScheduleSection(colors), + const SizedBox(height: 24), + _buildMeetingPointSection(colors), + const SizedBox(height: 24), + _buildDestinationAndStopsSection(colors), + const SizedBox(height: 24), + _buildMapPreviewSection(colors, snapshot), + const SizedBox(height: 24), + _buildPrivacySection(colors), + const SizedBox(height: 32), + _buildSubmitButton(colors), + const SizedBox(height: 32), + ], ); } @@ -732,6 +906,22 @@ class _CreateGroupRideScreenState extends ConsumerState { children: [ _buildSectionHeader('3. MEETING LOCATION', colors), const SizedBox(height: 10), + AddressAutocompleteField( + labelText: 'Meeting Place Address / Location *', + hintText: 'Search meetup address or landmark...', + initialPlace: _meetupPlace, + isRequired: true, + onPlaceResolved: (place) { + setState(() { + _meetupPlace = place; + if (place != null && _meetupNameController.text.trim().isEmpty) { + _meetupNameController.text = place.displayName; + } + }); + _triggerRouteCalculation(); + }, + ), + const SizedBox(height: 12), TextFormField( controller: _meetupNameController, style: const TextStyle(color: Colors.white), @@ -748,16 +938,6 @@ class _CreateGroupRideScreenState extends ConsumerState { }, ), const SizedBox(height: 12), - TextFormField( - controller: _meetupAddressController, - style: const TextStyle(color: Colors.white), - decoration: _inputDecoration( - 'Address or Landmark', - 'e.g. 123 Main St, Dahlonega, GA', - colors, - ), - ), - const SizedBox(height: 12), TextFormField( controller: _meetupNotesController, style: const TextStyle(color: Colors.white), @@ -772,6 +952,9 @@ class _CreateGroupRideScreenState extends ConsumerState { } Widget _buildDestinationAndStopsSection(MotorSignalThemeColors colors) { + final canAdd = _canAddStop; + final totalStops = _totalPlannedStops; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -783,10 +966,31 @@ class _CreateGroupRideScreenState extends ConsumerState { title: const Text('Specific Destination', style: TextStyle(color: Colors.white)), value: _hasDestination, - onChanged: (val) => setState(() => _hasDestination = val), + onChanged: (val) { + setState(() { + _hasDestination = val; + }); + _triggerRouteCalculation(); + }, ), if (_hasDestination) ...[ const SizedBox(height: 8), + AddressAutocompleteField( + labelText: 'Destination Address / Location *', + hintText: 'Search destination address or scenic peak...', + initialPlace: _destinationPlace, + isRequired: _hasDestination, + onPlaceResolved: (place) { + setState(() { + _destinationPlace = place; + if (place != null && _destNameController.text.trim().isEmpty) { + _destNameController.text = place.displayName; + } + }); + _triggerRouteCalculation(); + }, + ), + const SizedBox(height: 12), TextFormField( controller: _destNameController, style: const TextStyle(color: Colors.white), @@ -796,19 +1000,9 @@ class _CreateGroupRideScreenState extends ConsumerState { colors, ), ), - const SizedBox(height: 12), - TextFormField( - controller: _destAddressController, - style: const TextStyle(color: Colors.white), - decoration: _inputDecoration( - 'Destination Address (optional)', - 'e.g. Hiawassee, GA', - colors, - ), - ), ], const SizedBox(height: 16), - // Route Pre-fill / Attachment + // USE EXISTING ROUTE Card Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(12), @@ -828,7 +1022,7 @@ class _CreateGroupRideScreenState extends ConsumerState { Text( _selectedRouteTitle != null ? 'Route: $_selectedRouteTitle' - : 'Plan with Saved Route', + : 'Use Existing Stage 8 Route', style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, @@ -839,7 +1033,7 @@ class _CreateGroupRideScreenState extends ConsumerState { Text( _selectedRouteTitle != null ? '$_selectedRouteStopCount stops • ${(_selectedRouteDistanceMeters != null ? (_selectedRouteDistanceMeters! / 1609.34).toStringAsFixed(1) : 0)} mi' - : 'Optional: import stops & coordinates', + : 'Import saved route stops & coordinates', style: const TextStyle(color: Colors.white54, fontSize: 11), ), @@ -861,19 +1055,19 @@ class _CreateGroupRideScreenState extends ConsumerState { }, ) else - OutlinedButton( + ElevatedButton.icon( onPressed: () => _showSelectRouteSheet(colors), - style: OutlinedButton.styleFrom( + icon: const Icon(Icons.alt_route, size: 14), + label: const Text('USE EXISTING ROUTE'), + style: ElevatedButton.styleFrom( + backgroundColor: colors.cyberCyan.withValues(alpha: 0.15), + foregroundColor: colors.cyberCyan, side: BorderSide(color: colors.cyberCyan), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: Text( - 'IMPORT', - style: TextStyle( - color: colors.cyberCyan, + textStyle: const TextStyle( fontSize: 11, fontWeight: FontWeight.bold, ), @@ -885,22 +1079,29 @@ class _CreateGroupRideScreenState extends ConsumerState { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Flexible( + Flexible( child: Text( - 'Intermediate Stops', + 'Intermediate Stops ($totalStops/$_maxTechnicalStops max)', overflow: TextOverflow.ellipsis, - style: TextStyle( + style: const TextStyle( color: Colors.white, fontWeight: FontWeight.w600, + fontSize: 13, ), ), ), TextButton.icon( - onPressed: () => _addStopDialog(colors), + onPressed: canAdd ? () => _addStopDialog(colors) : null, icon: Icon(Icons.add_location_alt, - size: 16, color: colors.cyberCyan), - label: Text('ADD STOP', - style: TextStyle(color: colors.cyberCyan)), + size: 16, + color: canAdd ? colors.cyberCyan : Colors.white24), + label: Text( + canAdd ? 'ADD STOP' : 'LIMIT REACHED', + style: TextStyle( + color: canAdd ? colors.cyberCyan : Colors.white24, + fontWeight: FontWeight.bold, + ), + ), ), ], ), @@ -921,12 +1122,14 @@ class _CreateGroupRideScreenState extends ConsumerState { final item = _stops.removeAt(oldIndex); _stops.insert(newIndex, item); }); + _triggerRouteCalculation(); }, children: [ for (int i = 0; i < _stops.length; i++) ListTile( key: ValueKey('stop_$i'), tileColor: colors.surfaceContainerHigh, + onTap: () => _addStopDialog(colors, i), leading: CircleAvatar( radius: 12, backgroundColor: colors.cyberCyan.withValues(alpha: 0.2), @@ -945,14 +1148,19 @@ class _CreateGroupRideScreenState extends ConsumerState { const TextStyle(color: Colors.white, fontSize: 13), ), subtitle: Text( - _stops[i].locationType.label, + '${_stops[i].locationType.label}${_stops[i].address.isNotEmpty ? ' • ${_stops[i].address}' : ''}', style: const TextStyle(color: Colors.white54, fontSize: 11), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), trailing: IconButton( icon: const Icon(Icons.delete_outline, size: 18, color: Colors.white38), - onPressed: () => setState(() => _stops.removeAt(i)), + onPressed: () { + setState(() => _stops.removeAt(i)); + _triggerRouteCalculation(); + }, ), ), ], @@ -961,6 +1169,242 @@ class _CreateGroupRideScreenState extends ConsumerState { ); } + Widget _buildMapPreviewSection( + MotorSignalThemeColors colors, + ResponsiveSnapshot snapshot, + ) { + final markers = []; + 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 = []; + if (_calculatedRouteResult != null && + _calculatedRouteResult!.geometry.isNotEmpty) { + polylines.add( + AppPolyline( + id: 'preview_route_line', + points: _calculatedRouteResult!.geometry, + color: colors.cyberCyan, + width: 3.5, + ), + ); + } + + final center = _mapCenter ?? + _meetupPlace?.latLng ?? + _destinationPlace?.latLng ?? + const AppLatLng(34.5378, -83.9849); + + final mapHeight = snapshot.isLandscape ? 170.0 : 210.0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildSectionHeader('ROUTE PREVIEW', colors), + if (_calculatedRouteResult != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: colors.cyberCyan.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + _calculatedRouteResult!.distanceLabel, + style: TextStyle( + color: colors.cyberCyan, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Container( + height: mapHeight, + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colors.outlineVariant), + ), + child: Stack( + children: [ + Positioned.fill( + child: UnifiedMapView( + center: center, + zoom: _mapZoom, + markers: markers, + polylines: polylines, + ), + ), + if (_isCalculatingRoute) + Positioned( + top: 0, + left: 0, + right: 0, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + LinearProgressIndicator( + color: colors.cyberCyan, + backgroundColor: Colors.black26, + minHeight: 3, + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + color: Colors.black87, + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Calculating motorcycle route...', + style: TextStyle( + color: Colors.white70, fontSize: 11), + ), + ], + ), + ), + ], + ), + ), + if (_routeCalculationError != null) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 6), + color: + colors.surfaceContainerHigh.withValues(alpha: 0.95), + child: Row( + children: [ + Icon(Icons.warning_amber_rounded, + color: colors.warningAmber, size: 16), + const SizedBox(width: 8), + Expanded( + child: Text( + _routeCalculationError!, + style: const TextStyle( + color: Colors.white70, fontSize: 11), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + TextButton( + onPressed: _triggerRouteCalculation, + style: TextButton.styleFrom( + padding: + const EdgeInsets.symmetric(horizontal: 8), + minimumSize: Size.zero, + ), + child: Text('RETRY', + style: TextStyle( + color: colors.cyberCyan, fontSize: 11)), + ), + ], + ), + ), + ), + if (_calculatedRouteResult != null) + Positioned( + bottom: 4, + right: 6, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'Powered by Valhalla • Stadia Maps', + style: TextStyle(color: Colors.white38, fontSize: 8), + ), + ), + ), + ], + ), + ), + ), + ], + ); + } + Widget _buildPrivacySection(MotorSignalThemeColors colors) { return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/features/group_rides/live_group_ride_screen.dart b/lib/features/group_rides/live_group_ride_screen.dart index 5dfc25f..b7c5cda 100644 --- a/lib/features/group_rides/live_group_ride_screen.dart +++ b/lib/features/group_rides/live_group_ride_screen.dart @@ -476,14 +476,35 @@ class _LiveGroupRideScreenState extends ConsumerState ], ), actions: [ - if (isOrganizer) - IconButton( - icon: Icon( - Icons.stop_circle_outlined, - color: colors.raceRed, - ), - tooltip: 'End Ride', - onPressed: () => _confirmEndRide(context, ride, currentUid, colors), + if (isOrganizer && ride.status == GroupRideStatus.underway) + PopupMenuButton( + icon: const Icon(Icons.more_vert, color: Colors.white70), + tooltip: 'Ride Options', + color: colors.surfaceContainerHigh, + onSelected: (val) { + if (val == 'end_ride') { + _confirmEndRide(context, ride, currentUid, colors); + } + }, + itemBuilder: (ctx) => [ + PopupMenuItem( + value: 'end_ride', + child: Row( + children: [ + Icon(Icons.stop_circle_outlined, + color: colors.raceRed, size: 20), + const SizedBox(width: 10), + Text( + 'End Ride', + style: TextStyle( + color: colors.raceRed, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], ), IconButton( icon: Icon(Icons.people_outline, color: colors.cyberCyan), @@ -1441,11 +1462,11 @@ class _LiveGroupRideScreenState extends ConsumerState builder: (ctx) => AlertDialog( backgroundColor: colors.surfaceContainerHigh, title: const Text( - 'End Group Ride?', + 'End this group ride?', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold), ), content: const Text( - 'This will conclude the group ride for all participants and terminate live location sharing.', + 'Ending the ride will stop live location sharing and mark the Group Ride as completed.', style: TextStyle(color: Colors.white70), ), actions: [ @@ -1469,9 +1490,6 @@ class _LiveGroupRideScreenState extends ConsumerState groupRideId: ride.id, newStatus: GroupRideStatus.completed, ); - if (context.mounted) { - context.pop(); - } }, child: const Text('END RIDE'), ), diff --git a/lib/features/routes/route_planner_screen.dart b/lib/features/routes/route_planner_screen.dart index fad5000..1a1d378 100644 --- a/lib/features/routes/route_planner_screen.dart +++ b/lib/features/routes/route_planner_screen.dart @@ -12,6 +12,7 @@ import '../../services/routes/navigation_handoff.dart'; import '../../services/routes/route_models.dart'; import '../../services/routes/route_providers.dart'; import '../../services/routes/route_service.dart'; +import 'widgets/address_autocomplete_field.dart'; class RoutePlannerScreen extends ConsumerStatefulWidget { final String? initialRouteId; @@ -162,6 +163,34 @@ class _RoutePlannerScreenState extends ConsumerState { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + side: BorderSide(color: colors.cyberCyan), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + minimumSize: const Size(double.infinity, 36), + ), + icon: + Icon(Icons.search, size: 16, color: colors.cyberCyan), + label: Text( + 'SEARCH ADDRESS / POI', + style: TextStyle( + color: colors.cyberCyan, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + onPressed: () => _openAddressSearchForStop( + context, + colors, + setDialogState, + nameCtrl, + latCtrl, + lngCtrl, + notesCtrl, + ), + ), + const SizedBox(height: 12), TextField( controller: nameCtrl, style: const TextStyle(color: Colors.white), @@ -170,6 +199,20 @@ class _RoutePlannerScreenState extends ConsumerState { labelStyle: const TextStyle(color: Colors.white60), hintText: 'e.g. Skyline Summit, Fuel Stop', hintStyle: const TextStyle(color: Colors.white24), + suffixIcon: IconButton( + icon: Icon(Icons.search, + color: colors.cyberCyan, size: 20), + tooltip: 'Search address', + onPressed: () => _openAddressSearchForStop( + context, + colors, + setDialogState, + nameCtrl, + latCtrl, + lngCtrl, + notesCtrl, + ), + ), enabledBorder: UnderlineInputBorder( borderSide: BorderSide(color: colors.outlineVariant), ), @@ -353,6 +396,66 @@ class _RoutePlannerScreenState extends ConsumerState { ); } + 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 _saveRoute() async { final title = _titleController.text.trim(); if (title.length < 3 || title.length > 80) { diff --git a/lib/features/routes/widgets/address_autocomplete_field.dart b/lib/features/routes/widgets/address_autocomplete_field.dart new file mode 100644 index 0000000..1d3cb25 --- /dev/null +++ b/lib/features/routes/widgets/address_autocomplete_field.dart @@ -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 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 createState() => + _AddressAutocompleteFieldState(); +} + +class _AddressAutocompleteFieldState + extends ConsumerState { + late final TextEditingController _controller; + late final FocusNode _focusNode; + bool _internalController = false; + bool _internalFocusNode = false; + + ResolvedPlace? _resolvedPlace; + List _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 _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 _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, + ), + ), + ], + ), + ), + ), + ), + ); +} +} + diff --git a/test/address_autocomplete_field_test.dart b/test/address_autocomplete_field_test.dart new file mode 100644 index 0000000..472732f --- /dev/null +++ b/test/address_autocomplete_field_test.dart @@ -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); + }); + }); +} diff --git a/test/create_group_ride_screen_test.dart b/test/create_group_ride_screen_test.dart new file mode 100644 index 0000000..655e1d4 --- /dev/null +++ b/test/create_group_ride_screen_test.dart @@ -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 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 stops = const [], + List 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? 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(); + 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); + }); + + + + + + + + + }); +} diff --git a/test/fake_route_provider.dart b/test/fake_route_provider.dart index 4c5c4e6..328c631 100644 --- a/test/fake_route_provider.dart +++ b/test/fake_route_provider.dart @@ -1,14 +1,24 @@ import 'package:motorcycle_dash/services/map/map_provider_interface.dart'; import 'package:motorcycle_dash/services/routes/route_models.dart'; import 'package:motorcycle_dash/services/routes/route_provider.dart'; +import 'dart:async'; class FakeRouteProvider implements RouteProvider { final bool shouldFail; final bool shouldTimeout; + final List? customSearchResults; + final ResolvedPlace? customResolvedPlace; + final Future Function(int count)? onCalculate; + int _calcCount = 0; + + List? lastCapturedStops; FakeRouteProvider({ this.shouldFail = false, this.shouldTimeout = false, + this.customSearchResults, + this.customResolvedPlace, + this.onCalculate, }); @override @@ -21,6 +31,12 @@ class FakeRouteProvider implements RouteProvider { bool avoidTolls = false, bool avoidFerries = false, }) async { + lastCapturedStops = stops; + + if (onCalculate != null) { + await onCalculate!(_calcCount++); + } + if (shouldTimeout) { await Future.delayed(const Duration(seconds: 2)); throw RouteProviderException('providerTimeout', 'Fake timeout'); @@ -54,24 +70,17 @@ class FakeRouteProvider implements RouteProvider { ); } - @override - Future resolvePlace(String placeId) async { - if (shouldFail) throw RouteProviderException('placeNotFound', 'Fake not found'); - return ResolvedPlace( - providerId: placeId, - displayName: 'Fake Place', - latitude: 0.0, - longitude: 0.0, - attribution: 'Fake Attribution', - ); - } - @override Future> searchPlaces( String query, { AppLatLng? nearLocation, }) async { - if (shouldFail) throw RouteProviderException('placeSearchUnavailable', 'Fake search error'); + if (shouldFail) { + throw RouteProviderException('placeSearchUnavailable', 'Fake search error'); + } + + if (customSearchResults != null) return customSearchResults!; + if (query.length < 2) return []; return [ @@ -83,4 +92,19 @@ class FakeRouteProvider implements RouteProvider { ), ]; } + + @override + Future 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', + ); + } } diff --git a/test/live_group_ride_end_ride_test.dart b/test/live_group_ride_end_ride_test.dart new file mode 100644 index 0000000..77bcfc0 --- /dev/null +++ b/test/live_group_ride_end_ride_test.dart @@ -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 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)); + }); + }); +}