feat: add real route planning and group ride navigation experience

This commit is contained in:
Developer 2026-09-05 23:34:56 -04:00
parent 9958a8a600
commit 92ce23cdb7
8 changed files with 2863 additions and 248 deletions

File diff suppressed because it is too large Load Diff

View File

@ -476,14 +476,35 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
],
),
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<String>(
icon: const Icon(Icons.more_vert, color: Colors.white70),
tooltip: 'Ride Options',
color: colors.surfaceContainerHigh,
onSelected: (val) {
if (val == 'end_ride') {
_confirmEndRide(context, ride, currentUid, colors);
}
},
itemBuilder: (ctx) => [
PopupMenuItem(
value: 'end_ride',
child: Row(
children: [
Icon(Icons.stop_circle_outlined,
color: colors.raceRed, size: 20),
const SizedBox(width: 10),
Text(
'End Ride',
style: TextStyle(
color: colors.raceRed,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
IconButton(
icon: Icon(Icons.people_outline, color: colors.cyberCyan),
@ -1441,11 +1462,11 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
builder: (ctx) => AlertDialog(
backgroundColor: colors.surfaceContainerHigh,
title: const Text(
'End Group Ride?',
'End this group ride?',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
content: const Text(
'This will conclude the group ride for all participants and terminate live location sharing.',
'Ending the ride will stop live location sharing and mark the Group Ride as completed.',
style: TextStyle(color: Colors.white70),
),
actions: [
@ -1469,9 +1490,6 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
groupRideId: ride.id,
newStatus: GroupRideStatus.completed,
);
if (context.mounted) {
context.pop();
}
},
child: const Text('END RIDE'),
),

View File

@ -12,6 +12,7 @@ import '../../services/routes/navigation_handoff.dart';
import '../../services/routes/route_models.dart';
import '../../services/routes/route_providers.dart';
import '../../services/routes/route_service.dart';
import 'widgets/address_autocomplete_field.dart';
class RoutePlannerScreen extends ConsumerStatefulWidget {
final String? initialRouteId;
@ -162,6 +163,34 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
side: BorderSide(color: colors.cyberCyan),
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
minimumSize: const Size(double.infinity, 36),
),
icon:
Icon(Icons.search, size: 16, color: colors.cyberCyan),
label: Text(
'SEARCH ADDRESS / POI',
style: TextStyle(
color: colors.cyberCyan,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
onPressed: () => _openAddressSearchForStop(
context,
colors,
setDialogState,
nameCtrl,
latCtrl,
lngCtrl,
notesCtrl,
),
),
const SizedBox(height: 12),
TextField(
controller: nameCtrl,
style: const TextStyle(color: Colors.white),
@ -170,6 +199,20 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
labelStyle: const TextStyle(color: Colors.white60),
hintText: 'e.g. Skyline Summit, Fuel Stop',
hintStyle: const TextStyle(color: Colors.white24),
suffixIcon: IconButton(
icon: Icon(Icons.search,
color: colors.cyberCyan, size: 20),
tooltip: 'Search address',
onPressed: () => _openAddressSearchForStop(
context,
colors,
setDialogState,
nameCtrl,
latCtrl,
lngCtrl,
notesCtrl,
),
),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: colors.outlineVariant),
),
@ -353,6 +396,66 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
);
}
void _openAddressSearchForStop(
BuildContext context,
MotorSignalThemeColors colors,
void Function(void Function()) setDialogState,
TextEditingController nameCtrl,
TextEditingController latCtrl,
TextEditingController lngCtrl,
TextEditingController notesCtrl,
) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: colors.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (sheetCtx) => Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: MediaQuery.of(sheetCtx).viewInsets.bottom + 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'SEARCH ADDRESS OR PLACE',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
const SizedBox(height: 12),
AddressAutocompleteField(
labelText: 'Search location',
autofocus: true,
onPlaceResolved: (place) {
if (place != null) {
setDialogState(() {
nameCtrl.text = place.displayName;
latCtrl.text = place.latitude.toStringAsFixed(6);
lngCtrl.text = place.longitude.toStringAsFixed(6);
if (notesCtrl.text.isEmpty &&
place.formattedAddress != null) {
notesCtrl.text = place.formattedAddress!;
}
});
Navigator.pop(sheetCtx);
}
},
),
],
),
),
);
}
Future<void> _saveRoute() async {
final title = _titleController.text.trim();
if (title.length < 3 || title.length > 80) {

View File

@ -0,0 +1,495 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/theme_extensions.dart';
import '../../../services/map/map_provider_interface.dart';
import '../../../services/routes/route_provider.dart';
import '../../../services/routes/route_providers.dart';
/// Reusable address and place autocomplete field for MotorSignal.
///
/// Integrates with [RouteProvider] (Stadia Maps / Pelias) to provide:
/// - 350ms debounced search
/// - Sequence-guarded asynchronous responses to prevent stale data
/// - Immediate resolution when coordinates are present in search results
/// - Invalidation of previous resolution upon text modification
/// - Accessible suggestions list with loading, no-results, and error states
class AddressAutocompleteField extends ConsumerStatefulWidget {
final String labelText;
final String? hintText;
final ResolvedPlace? initialPlace;
final String? initialQuery;
final ValueChanged<ResolvedPlace?> onPlaceResolved;
final AppLatLng? nearLocation;
final String? Function(String?)? validator;
final bool enabled;
final bool autofocus;
final FocusNode? focusNode;
final TextEditingController? controller;
final Widget? prefixIcon;
final bool showAttribution;
final VoidCallback? onClear;
final bool isRequired;
const AddressAutocompleteField({
super.key,
required this.labelText,
this.hintText,
this.initialPlace,
this.initialQuery,
required this.onPlaceResolved,
this.nearLocation,
this.validator,
this.enabled = true,
this.autofocus = false,
this.focusNode,
this.controller,
this.prefixIcon,
this.showAttribution = true,
this.onClear,
this.isRequired = false,
});
@override
ConsumerState<AddressAutocompleteField> createState() =>
_AddressAutocompleteFieldState();
}
class _AddressAutocompleteFieldState
extends ConsumerState<AddressAutocompleteField> {
late final TextEditingController _controller;
late final FocusNode _focusNode;
bool _internalController = false;
bool _internalFocusNode = false;
ResolvedPlace? _resolvedPlace;
List<PlaceSearchResult> _suggestions = const [];
bool _isLoading = false;
bool _isResolving = false;
String? _errorMessage;
Timer? _debounceTimer;
int _searchSequence = 0;
bool _showDropdown = false;
bool _hasSearched = false;
@override
void initState() {
super.initState();
if (widget.controller != null) {
_controller = widget.controller!;
} else {
_controller = TextEditingController(
text: widget.initialPlace?.displayName ?? widget.initialQuery ?? '',
);
_internalController = true;
}
if (widget.focusNode != null) {
_focusNode = widget.focusNode!;
} else {
_focusNode = FocusNode();
_internalFocusNode = true;
}
_resolvedPlace = widget.initialPlace;
_focusNode.addListener(_handleFocusChange);
}
@override
void didUpdateWidget(covariant AddressAutocompleteField oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.initialPlace != oldWidget.initialPlace) {
_resolvedPlace = widget.initialPlace;
final newText = widget.initialPlace?.displayName ?? '';
if (_controller.text != newText) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _controller.text != newText) {
_controller.text = newText;
}
});
}
}
}
@override
void dispose() {
_debounceTimer?.cancel();
_focusNode.removeListener(_handleFocusChange);
if (_internalFocusNode) {
_focusNode.dispose();
}
if (_internalController) {
_controller.dispose();
}
super.dispose();
}
void _handleFocusChange() {
if (!_focusNode.hasFocus) {
// Delay closing dropdown slightly so tap on suggestion can register
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted && !_focusNode.hasFocus) {
setState(() {
_showDropdown = false;
});
}
});
}
}
void _onTextChanged(String text) {
// If the text was previously resolved, modifying text invalidates the resolution
if (_resolvedPlace != null) {
_resolvedPlace = null;
widget.onPlaceResolved(null);
}
_debounceTimer?.cancel();
final trimmed = text.trim();
if (trimmed.length < 2) {
setState(() {
_suggestions = const [];
_isLoading = false;
_showDropdown = false;
_errorMessage = null;
_hasSearched = false;
});
return;
}
_debounceTimer = Timer(const Duration(milliseconds: 350), () {
_performSearch(trimmed);
});
}
Future<void> _performSearch(String query) async {
final seq = ++_searchSequence;
setState(() {
_isLoading = true;
_errorMessage = null;
_hasSearched = true;
_showDropdown = true;
});
try {
final provider = ref.read(activeRouteCalculationProvider);
final results = await provider.searchPlaces(
query,
nearLocation: widget.nearLocation,
);
if (!mounted || seq != _searchSequence) return;
setState(() {
_suggestions = results;
_isLoading = false;
});
} catch (e) {
if (!mounted || seq != _searchSequence) return;
final msg = e is RouteProviderException
? e.message
: e.toString()
.replaceAll('RouteProviderException: ', '')
.replaceAll('Exception: ', '');
setState(() {
_isLoading = false;
_errorMessage = msg.isNotEmpty ? msg : 'Unable to search addresses.';
});
}
}
Future<void> _selectSuggestion(PlaceSearchResult suggestion) async {
// If coordinates are already present in search result, resolve immediately without secondary call
if (suggestion.hasCoordinates) {
final place = suggestion.toResolvedPlace()!;
_applyResolvedPlace(place);
return;
}
// Fallback: resolve via provider
setState(() {
_isResolving = true;
_errorMessage = null;
});
try {
final provider = ref.read(activeRouteCalculationProvider);
final place = await provider.resolvePlace(suggestion.providerId);
if (!mounted) return;
_applyResolvedPlace(place);
} catch (e) {
if (!mounted) return;
setState(() {
_isResolving = false;
_errorMessage = 'Failed to resolve location coordinates.';
});
}
}
void _applyResolvedPlace(ResolvedPlace place) {
_resolvedPlace = place;
_controller.text = place.displayName;
setState(() {
_isResolving = false;
_suggestions = const [];
_showDropdown = false;
_errorMessage = null;
});
_focusNode.unfocus();
widget.onPlaceResolved(place);
}
void _clear() {
_controller.clear();
_resolvedPlace = null;
_suggestions = const [];
_showDropdown = false;
_errorMessage = null;
_hasSearched = false;
setState(() {});
widget.onPlaceResolved(null);
widget.onClear?.call();
}
@override
Widget build(BuildContext context) {
final colors = context.motorColors;
final isResolved = _resolvedPlace != null;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _controller,
focusNode: _focusNode,
enabled: widget.enabled && !_isResolving,
autofocus: widget.autofocus,
style: const TextStyle(color: Colors.white, fontSize: 14),
onChanged: _onTextChanged,
validator: (val) {
if (widget.isRequired && (val == null || val.trim().isEmpty)) {
return 'This location is required';
}
if (widget.isRequired && _resolvedPlace == null) {
return 'Please select a valid address from the suggestions';
}
if (widget.validator != null) {
return widget.validator!(val);
}
return null;
},
decoration: InputDecoration(
labelText: widget.labelText,
hintText: widget.hintText ?? 'Type address or place name...',
labelStyle: TextStyle(
color: isResolved ? colors.launchGreen : Colors.white70,
fontSize: 13,
),
hintStyle: const TextStyle(color: Colors.white24, fontSize: 13),
filled: true,
fillColor: colors.surfaceContainerHigh,
prefixIcon: widget.prefixIcon ??
(isResolved
? Icon(Icons.check_circle,
color: colors.launchGreen, size: 20)
: Icon(Icons.search, color: colors.cyberCyan, size: 20)),
suffixIcon: _isResolving
? Padding(
padding: const EdgeInsets.all(12),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colors.cyberCyan,
),
),
)
: _controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear,
size: 18, color: Colors.white54),
onPressed: _clear,
tooltip: 'Clear location',
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: isResolved ? colors.launchGreen : colors.outlineVariant,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: isResolved
? colors.launchGreen.withValues(alpha: 0.6)
: colors.outlineVariant,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: isResolved ? colors.launchGreen : colors.cyberCyan,
width: 1.5,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: colors.raceRed),
),
),
),
if (_showDropdown) ...[
const SizedBox(height: 4),
_buildDropdownPanel(colors),
],
],
);
}
Widget _buildDropdownPanel(MotorSignalThemeColors colors) {
return Material(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
elevation: 4,
child: Container(
constraints: const BoxConstraints(maxHeight: 200),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colors.outlineVariant),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_isLoading)
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colors.cyberCyan,
),
),
const SizedBox(width: 12),
const Text(
'Searching addresses...',
style: TextStyle(color: Colors.white70, fontSize: 12),
),
],
),
)
else if (_errorMessage != null)
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Icon(Icons.warning_amber_rounded,
color: colors.warningAmber, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
_errorMessage!,
style: const TextStyle(
color: Colors.white70, fontSize: 12),
),
),
TextButton(
onPressed: () => _performSearch(_controller.text.trim()),
child: Text(
'RETRY',
style:
TextStyle(color: colors.cyberCyan, fontSize: 11),
),
),
],
),
)
else if (_hasSearched && _suggestions.isEmpty)
const Padding(
padding: EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.location_off, color: Colors.white38, size: 16),
SizedBox(width: 8),
Text(
'No matching addresses found',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
],
),
)
else
for (int i = 0; i < _suggestions.length; i++) ...[
if (i > 0) const Divider(height: 1, color: Colors.white10),
ListTile(
dense: true,
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
leading: Icon(
Icons.place_outlined,
color: colors.cyberCyan,
size: 20,
),
title: Text(
_suggestions[i].primaryText,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: _suggestions[i].secondaryText.isNotEmpty
? Text(
_suggestions[i].secondaryText,
style: const TextStyle(
color: Colors.white54,
fontSize: 11,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: null,
onTap: () => _selectSuggestion(_suggestions[i]),
),
],
if (widget.showAttribution)
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
color: colors.surfaceContainer,
child: const Text(
'Search by Stadia Maps / Pelias • © OpenStreetMap contributors',
style: TextStyle(
color: Colors.white38,
fontSize: 9,
letterSpacing: 0.2,
),
textAlign: TextAlign.center,
),
),
],
),
),
),
),
);
}
}

View File

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

View File

@ -0,0 +1,962 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'dart:async';
import 'package:motorcycle_dash/features/group_rides/create_group_ride_screen.dart';
import 'package:motorcycle_dash/services/auth/auth_providers.dart';
import 'package:motorcycle_dash/services/auth/auth_user.dart';
import 'package:motorcycle_dash/services/group_rides/group_ride_models.dart';
import 'package:motorcycle_dash/services/group_rides/group_ride_service.dart';
import 'package:motorcycle_dash/services/group_rides/group_ride_providers.dart';
import 'package:motorcycle_dash/services/routes/route_models.dart';
import 'package:motorcycle_dash/services/routes/route_provider.dart';
import 'package:motorcycle_dash/services/routes/route_providers.dart';
import 'package:motorcycle_dash/services/social/rider_profile_models.dart';
import 'package:motorcycle_dash/services/social/social_providers.dart';
import 'package:motorcycle_dash/features/routes/widgets/address_autocomplete_field.dart';
import 'fake_route_provider.dart';
class _MockGroupRideService extends GroupRideService {
GroupRide? createdRide;
@override
Future<GroupRide> createGroupRide({
required RiderProfile organizer,
required String title,
String description = '',
GroupRideVisibility visibility = GroupRideVisibility.friendsOnly,
GroupRideJoinPolicy joinPolicy = GroupRideJoinPolicy.inviteOnly,
required DateTime scheduledStartAt,
required DateTime departureAt,
int? participantLimit,
required GroupRideLocation meetingPoint,
GroupRideLocation? destination,
List<GroupRideLocation> stops = const [],
List<String> initialInviteUids = const [],
String? routeId,
String? routeTitle,
double? routeDistanceMeters,
int? routeStopCount,
}) async {
final now = DateTime.now();
createdRide = GroupRide(
id: 'ride_new_test',
organizerUid: organizer.uid,
organizerHandle: organizer.handle,
organizerDisplayName: organizer.displayName,
title: title,
description: description,
visibility: visibility,
joinPolicy: joinPolicy,
scheduledStartAt: scheduledStartAt,
departureAt: departureAt,
participantLimit: participantLimit,
meetingPoint: meetingPoint,
destination: destination,
stops: stops,
routeId: routeId,
routeTitle: routeTitle,
createdAt: now,
updatedAt: now,
);
return createdRide!;
}
}
void main() {
group('CreateGroupRideScreen Route-P2 Tests', () {
late RiderProfile profile;
late AuthUser user;
setUp(() {
final now = DateTime.now();
profile = RiderProfile(
uid: 'alice',
handle: 'alice_apex',
displayName: 'Alice Apex',
createdAt: now,
updatedAt: now,
);
user = const AuthUser(
uid: 'alice',
email: 'alice@example.com',
);
});
testWidgets('ambiguous AppBar Import icon is removed and USE EXISTING ROUTE action is visible',
(tester) async {
await tester.binding.setSurfaceSize(const Size(400, 900));
await tester.binding.setSurfaceSize(const Size(800, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWithValue(user),
myRiderProfileProvider.overrideWith((ref) => Stream.value(RiderProfile(uid: 'alice', handle: 'alice_apex', displayName: 'Alice Apex', createdAt: DateTime.now(), updatedAt: DateTime.now()))),
],
child: const MaterialApp(debugShowCheckedModeBanner: false,
home: CreateGroupRideScreen(),
),
),
);
await tester.pumpAndSettle();
// Verify ambiguous AppBar icon is removed
final appBarFinder = find.byType(AppBar);
expect(
find.descendant(of: appBarFinder, matching: find.byIcon(Icons.alt_route)),
findsNothing,
);
// Verify explicit form button "USE EXISTING ROUTE" is visible
expect(find.text('USE EXISTING ROUTE'), findsOneWidget);
});
testWidgets('blocks publish when Meetup or Destination is unresolved text',
(tester) async {
await tester.binding.setSurfaceSize(const Size(800, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
final mockService = _MockGroupRideService();
await tester.binding.setSurfaceSize(const Size(800, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWithValue(user),
myRiderProfileProvider.overrideWith((ref) => Stream.value(RiderProfile(uid: 'alice', handle: 'alice_apex', displayName: 'Alice Apex', createdAt: DateTime.now(), updatedAt: DateTime.now()))),
groupRideServiceProvider.overrideWithValue(mockService),
],
child: const MaterialApp(debugShowCheckedModeBanner: false,
home: CreateGroupRideScreen(),
),
),
);
await tester.pumpAndSettle();
// Enter valid title
final titleField = find.widgetWithText(TextFormField, 'Ride Title');
await tester.enterText(titleField, 'Weekend Cruise');
await tester.pumpAndSettle();
// Enter unresolved text into Meetup field
final startSearchField = find.widgetWithText(
AddressAutocompleteField,
'Meeting Place Address / Location *',
);
final startInput = find.descendant(
of: startSearchField,
matching: find.byType(TextFormField),
);
await tester.enterText(startInput, 'Unresolved Meetup Spot');
await tester.pumpAndSettle();
// Do not select resolved meetup from suggestions, just tap PUBLISH
await tester.tap(find.text('PUBLISH'));
await tester.pumpAndSettle();
// Validation error shown
expect(
find.textContaining('Please select a valid address from the suggestions'),
findsOneWidget,
);
expect(mockService.createdRide, isNull);
});
testWidgets('calculates route and displays preview when Start and Destination resolved',
(tester) async {
await tester.binding.setSurfaceSize(const Size(800, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
final fake = FakeRouteProvider(
customSearchResults: [
const PlaceSearchResult(
providerId: 'meetup_1',
primaryText: 'Dahlonega Chevron',
secondaryText: 'Dahlonega, GA',
latitude: 34.53,
longitude: -83.98,
),
const PlaceSearchResult(
providerId: 'dest_1',
primaryText: 'Neels Gap',
secondaryText: 'Blairsville, GA',
latitude: 34.88,
longitude: -83.92,
),
],
);
await tester.binding.setSurfaceSize(const Size(800, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWithValue(user),
myRiderProfileProvider.overrideWith((ref) => Stream.value(RiderProfile(uid: 'alice', handle: 'alice_apex', displayName: 'Alice Apex', createdAt: DateTime.now(), updatedAt: DateTime.now()))),
activeRouteCalculationProvider.overrideWithValue(fake),
],
child: const MaterialApp(debugShowCheckedModeBanner: false,
home: CreateGroupRideScreen(),
),
),
);
await tester.pumpAndSettle();
// Fill Start address
final startSearchField = find.widgetWithText(
AddressAutocompleteField,
'Meeting Place Address / Location *',
);
final startInput = find.descendant(
of: startSearchField,
matching: find.byType(TextFormField),
);
await tester.enterText(startInput, 'Dahlonega');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
await tester.tap(find.text('Dahlonega Chevron').last);
await tester.pumpAndSettle();
// Fill Destination address
final destSearchField = find.widgetWithText(
AddressAutocompleteField,
'Destination Address / Location *',
);
final destInput = find.descendant(
of: destSearchField,
matching: find.byType(TextFormField),
);
await tester.enterText(destInput, 'Neels');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
await tester.tap(find.text('Neels Gap').last);
await tester.pumpAndSettle();
// Verify route preview section rendered with distance
expect(find.text('ROUTE PREVIEW'), findsOneWidget);
expect(find.textContaining('Road Est.'), findsOneWidget);
expect(find.text('Powered by Valhalla • Stadia Maps'), findsOneWidget);
});
testWidgets('allows adding, editing, and removing intermediate stops',
(tester) async {
await tester.binding.setSurfaceSize(const Size(800, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
final fake = FakeRouteProvider(
customSearchResults: [
const PlaceSearchResult(
providerId: 'stop_p1',
primaryText: 'Turner Corner Gas',
secondaryText: 'Cleveland, GA',
latitude: 34.68,
longitude: -83.89,
),
],
);
await tester.binding.setSurfaceSize(const Size(800, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWithValue(user),
myRiderProfileProvider.overrideWith((ref) => Stream.value(RiderProfile(uid: 'alice', handle: 'alice_apex', displayName: 'Alice Apex', createdAt: DateTime.now(), updatedAt: DateTime.now()))),
activeRouteCalculationProvider.overrideWithValue(fake),
],
child: const MaterialApp(debugShowCheckedModeBanner: false,
home: CreateGroupRideScreen(),
),
),
);
await tester.pumpAndSettle();
// Tap ADD STOP
await tester.tap(find.text('ADD STOP'));
await tester.pumpAndSettle();
expect(find.text('Add Intermediate Stop'), findsOneWidget);
// Search stop address
final dialogSearch = find.descendant(
of: find.byType(AlertDialog),
matching: find.byType(AddressAutocompleteField),
);
final dialogSearchInput = find.descendant(
of: dialogSearch,
matching: find.byType(TextFormField),
);
await tester.enterText(dialogSearchInput, 'Turner');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
await tester.tap(find.text('Turner Corner Gas'));
await tester.pumpAndSettle();
// Add Stop
await tester.tap(find.text('ADD STOP').last);
await tester.pumpAndSettle();
// Verify stop added in intermediate list
expect(find.text('Turner Corner Gas'), findsOneWidget);
expect(find.textContaining('Intermediate Stops (2/25 max)'), findsOneWidget);
// Delete stop
await tester.tap(find.byIcon(Icons.delete_outline));
await tester.pumpAndSettle();
expect(find.text('Turner Corner Gas'), findsNothing);
expect(find.textContaining('Intermediate Stops (1/25 max)'), findsOneWidget);
});
testWidgets('USE EXISTING ROUTE loads saved route stops and recalculates route',
(tester) async {
await tester.binding.setSurfaceSize(const Size(800, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
final savedRoute = MotorSignalRoute(
id: 'route_saved_1',
title: 'Georgia Mountain Loop',
description: 'Twisty scenic mountain ride',
ownerUid: 'alice',
ownerHandle: 'alice_apex',
visibility: RouteVisibility.public,
straightLineDistanceMeters: 55000,
stops: const [
RouteStop(
id: 's_start',
displayName: 'Suches Community Center',
latitude: 34.689,
longitude: -84.025,
order: 0,
stopType: RouteStopType.origin,
),
RouteStop(
id: 's_mid',
displayName: 'Vogel State Park',
latitude: 34.764,
longitude: -83.924,
order: 1,
stopType: RouteStopType.waypoint,
),
RouteStop(
id: 's_end',
displayName: 'Two Wheels of Suches',
latitude: 34.693,
longitude: -84.023,
order: 2,
stopType: RouteStopType.destination,
),
],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
final fake = FakeRouteProvider();
await tester.binding.setSurfaceSize(const Size(800, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWithValue(user),
myRiderProfileProvider.overrideWith((ref) => Stream.value(RiderProfile(uid: 'alice', handle: 'alice_apex', displayName: 'Alice Apex', createdAt: DateTime.now(), updatedAt: DateTime.now()))),
userRoutesProvider('alice').overrideWith((ref) => Stream.value([savedRoute])),
activeRouteCalculationProvider.overrideWithValue(fake),
],
child: const MaterialApp(debugShowCheckedModeBanner: false,
home: CreateGroupRideScreen(),
),
),
);
await tester.pumpAndSettle();
// Tap USE EXISTING ROUTE
await tester.tap(find.text('USE EXISTING ROUTE'));
await tester.pumpAndSettle();
// Verify bottom sheet appears
expect(find.text('Georgia Mountain Loop'), findsOneWidget);
// Select route
await tester.tap(find.text('Georgia Mountain Loop'));
await tester.pumpAndSettle();
// Verify fields populated
expect(find.text('Georgia Mountain Loop'), findsWidgets);
expect(find.text('Suches Community Center'), findsWidgets);
expect(find.text('Vogel State Park'), findsOneWidget);
expect(find.text('Two Wheels of Suches'), findsWidgets);
expect(find.text('Route: Georgia Mountain Loop'), findsOneWidget);
// Verify route preview recalculated
expect(find.text('ROUTE PREVIEW'), findsOneWidget);
});
testWidgets('submits and publishes GroupRide with valid resolved locations and stops',
(tester) async {
await tester.binding.setSurfaceSize(const Size(800, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
final mockService = _MockGroupRideService();
final fake = FakeRouteProvider(
customSearchResults: [
const PlaceSearchResult(
providerId: 'meetup_1',
primaryText: 'Dahlonega Chevron',
secondaryText: 'Dahlonega, GA',
latitude: 34.53,
longitude: -83.98,
),
const PlaceSearchResult(
providerId: 'dest_1',
primaryText: 'Neels Gap',
secondaryText: 'Blairsville, GA',
latitude: 34.88,
longitude: -83.92,
),
],
);
await tester.binding.setSurfaceSize(const Size(800, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWithValue(user),
myRiderProfileProvider.overrideWith((ref) => Stream.value(RiderProfile(uid: 'alice', handle: 'alice_apex', displayName: 'Alice Apex', createdAt: DateTime.now(), updatedAt: DateTime.now()))),
activeRouteCalculationProvider.overrideWithValue(fake),
groupRideServiceProvider.overrideWithValue(mockService),
],
child: const MaterialApp(debugShowCheckedModeBanner: false,
home: CreateGroupRideScreen(),
),
),
);
await tester.pumpAndSettle();
// Fill title
final titleField = find.widgetWithText(TextFormField, 'Ride Title');
await tester.enterText(titleField, 'Blood Mountain Fast Run');
// Meetup
final startSearchField = find.widgetWithText(
AddressAutocompleteField,
'Meeting Place Address / Location *',
);
final startInput = find.descendant(
of: startSearchField,
matching: find.byType(TextFormField),
);
await tester.enterText(startInput, 'Dahlonega');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
await tester.tap(find.text('Dahlonega Chevron').last);
await tester.pumpAndSettle();
// Destination
final destSearchField = find.widgetWithText(
AddressAutocompleteField,
'Destination Address / Location *',
);
final destInput = find.descendant(
of: destSearchField,
matching: find.byType(TextFormField),
);
await tester.enterText(destInput, 'Neels');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
await tester.tap(find.text('Neels Gap').last);
await tester.pumpAndSettle();
// Tap PUBLISH
await tester.tap(find.text('PUBLISH'));
await tester.pumpAndSettle();
// Verify ride created with correct coordinates
expect(mockService.createdRide, isNotNull);
expect(mockService.createdRide!.title, 'Blood Mountain Fast Run');
expect(mockService.createdRide!.meetingPoint.displayName, 'Dahlonega Chevron');
expect(mockService.createdRide!.meetingPoint.latitude, 34.53);
expect(mockService.createdRide!.destination!.displayName, 'Neels Gap');
expect(mockService.createdRide!.destination!.latitude, 34.88);
});
});
group('ROUTE-P2 Final Pre-Commit Forensic Gate', () {
testWidgets('VERIFY EXACT ROUTE ORDER: Start + Destination', (tester) async {
await tester.binding.setSurfaceSize(const Size(800, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
final mockService = _MockGroupRideService();
final fakeRouteProvider = FakeRouteProvider(
customSearchResults: [
const PlaceSearchResult(providerId: 'meetup_1', primaryText: 'Dahlonega Chevron', secondaryText: 'Dahlonega, GA', latitude: 34.53, longitude: -83.98),
const PlaceSearchResult(providerId: 'stop_2', primaryText: 'Stop 2 Place', secondaryText: 'Stop 2 City', latitude: 34.70, longitude: -83.90),
const PlaceSearchResult(providerId: 'dest_1', primaryText: 'Neels Gap', secondaryText: 'Blood Mountain', latitude: 34.88, longitude: -83.99),
],
);
await tester.pumpWidget(
ProviderScope(
overrides: [
groupRideServiceProvider.overrideWithValue(mockService),
myRiderProfileProvider.overrideWith((_) => Stream.value(RiderProfile(
uid: 'uid_123',
handle: 'testrider', displayName: 'Test Rider', createdAt: DateTime.now(), updatedAt: DateTime.now(),
))),
activeRouteCalculationProvider.overrideWithValue(fakeRouteProvider),
],
child: const MaterialApp(
debugShowCheckedModeBanner: false,
home: Material(
child: CreateGroupRideScreen(),
),
),
),
);
await tester.pumpAndSettle();
// Meetup
final searchField = find.widgetWithText(AddressAutocompleteField, 'Meeting Place Address / Location *');
final input = find.descendant(of: searchField, matching: find.byType(TextFormField));
await tester.enterText(input, 'Start');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
await tester.tap(find.text('Dahlonega Chevron').last);
await tester.pumpAndSettle();
// Destination (already visible, hasDestination=true by default)
final destSearchField = find.widgetWithText(AddressAutocompleteField, 'Destination Address / Location *');
final destInput = find.descendant(of: destSearchField, matching: find.byType(TextFormField));
await tester.enterText(destInput, 'Neels');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
await tester.tap(find.text('Neels Gap').last);
await tester.pumpAndSettle();
expect(fakeRouteProvider.lastCapturedStops, isNotNull);
final stops = fakeRouteProvider.lastCapturedStops!;
expect(stops.length, 2);
expect(stops[0].stopType, RouteStopType.origin);
expect(stops[0].displayName, 'Dahlonega Chevron');
expect(stops[1].stopType, RouteStopType.destination);
expect(stops[1].displayName, 'Neels Gap');
});
testWidgets('VERIFY EXACT ROUTE ORDER: Start + Stop1 + Destination', (tester) async {
await tester.binding.setSurfaceSize(const Size(800, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
final mockService = _MockGroupRideService();
final fakeRouteProvider = FakeRouteProvider(
customSearchResults: [
const PlaceSearchResult(providerId: 'meetup_1', primaryText: 'Dahlonega Chevron', secondaryText: 'Dahlonega, GA', latitude: 34.53, longitude: -83.98),
const PlaceSearchResult(providerId: 'stop_2', primaryText: 'Stop 2 Place', secondaryText: 'Stop 2 City', latitude: 34.70, longitude: -83.90),
const PlaceSearchResult(providerId: 'dest_1', primaryText: 'Neels Gap', secondaryText: 'Blood Mountain', latitude: 34.88, longitude: -83.99),
],
);
await tester.pumpWidget(
ProviderScope(
overrides: [
groupRideServiceProvider.overrideWithValue(mockService),
myRiderProfileProvider.overrideWith((_) => Stream.value(RiderProfile(
uid: 'uid_123',
handle: 'testrider', displayName: 'Test Rider', createdAt: DateTime.now(), updatedAt: DateTime.now(),
))),
activeRouteCalculationProvider.overrideWithValue(fakeRouteProvider),
],
child: const MaterialApp(
debugShowCheckedModeBanner: false,
home: Material(
child: CreateGroupRideScreen(),
),
),
),
);
await tester.pumpAndSettle();
// Meetup
final searchField = find.widgetWithText(AddressAutocompleteField, 'Meeting Place Address / Location *');
final input = find.descendant(of: searchField, matching: find.byType(TextFormField));
await tester.enterText(input, 'Start');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
await tester.tap(find.text('Dahlonega Chevron').last);
await tester.pumpAndSettle();
// Stop 1
await tester.dragUntilVisible(find.text('ADD STOP').first, find.byType(Scrollable).first, const Offset(0, -100)); await tester.pumpAndSettle(); await tester.tap(find.text('ADD STOP').first);
await tester.pumpAndSettle();
final stopSearchField = find.widgetWithText(AddressAutocompleteField, 'Stop Address / Location *');
final stopInput = find.descendant(of: stopSearchField, matching: find.byType(TextFormField));
await tester.enterText(stopInput, 'Stop1');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
await tester.tap(find.text('Dahlonega Chevron').last); // Fake returns this for everything
await tester.pumpAndSettle();
await tester.tap(find.text('ADD STOP').last); // Add button in dialog
await tester.pumpAndSettle();
// Destination
final destSearchField = find.widgetWithText(AddressAutocompleteField, 'Destination Address / Location *');
final destInput = find.descendant(of: destSearchField, matching: find.byType(TextFormField));
await tester.enterText(destInput, 'Neels');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
await tester.tap(find.text('Neels Gap').last);
await tester.pumpAndSettle();
expect(fakeRouteProvider.lastCapturedStops, isNotNull);
final stops = fakeRouteProvider.lastCapturedStops!;
expect(stops.length, 3);
expect(stops[0].stopType, RouteStopType.origin);
expect(stops[1].stopType, RouteStopType.waypoint);
expect(stops[2].stopType, RouteStopType.destination);
});
testWidgets('VERIFY EXACT ROUTE ORDER: Start + Stop1 + Stop2 + Destination', (tester) async {
await tester.binding.setSurfaceSize(const Size(800, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
final mockService = _MockGroupRideService();
final fakeRouteProvider = FakeRouteProvider(
customSearchResults: [
const PlaceSearchResult(providerId: 'meetup_1', primaryText: 'Dahlonega Chevron', secondaryText: 'Dahlonega, GA', latitude: 34.53, longitude: -83.98),
const PlaceSearchResult(providerId: 'stop_2', primaryText: 'Stop 2 Place', secondaryText: 'Stop 2 City', latitude: 34.70, longitude: -83.90),
const PlaceSearchResult(providerId: 'dest_1', primaryText: 'Neels Gap', secondaryText: 'Blood Mountain', latitude: 34.88, longitude: -83.99),
],
);
await tester.pumpWidget(
ProviderScope(
overrides: [
groupRideServiceProvider.overrideWithValue(mockService),
myRiderProfileProvider.overrideWith((_) => Stream.value(RiderProfile(
uid: 'uid_123',
handle: 'testrider', displayName: 'Test Rider', createdAt: DateTime.now(), updatedAt: DateTime.now(),
))),
activeRouteCalculationProvider.overrideWithValue(fakeRouteProvider),
],
child: const MaterialApp(
debugShowCheckedModeBanner: false,
home: Material(
child: CreateGroupRideScreen(),
),
),
),
);
await tester.pumpAndSettle();
// Meetup
final searchField = find.widgetWithText(AddressAutocompleteField, 'Meeting Place Address / Location *');
final input = find.descendant(of: searchField, matching: find.byType(TextFormField));
await tester.enterText(input, 'Start');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
await tester.tap(find.text('Dahlonega Chevron').last);
await tester.pumpAndSettle();
// Stop 1
await tester.dragUntilVisible(find.text('ADD STOP').first, find.byType(Scrollable).first, const Offset(0, -100)); await tester.pumpAndSettle(); await tester.tap(find.text('ADD STOP').first);
await tester.pumpAndSettle();
final stopSearchField1 = find.widgetWithText(AddressAutocompleteField, 'Stop Address / Location *');
final stopInput1 = find.descendant(of: stopSearchField1, matching: find.byType(TextFormField));
await tester.enterText(stopInput1, 'Stop1');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
await tester.tap(find.text('Dahlonega Chevron').last);
await tester.pumpAndSettle();
await tester.tap(find.text('ADD STOP').last);
await tester.pumpAndSettle();
// Stop 2
await tester.dragUntilVisible(find.text('ADD STOP').first, find.byType(Scrollable).first, const Offset(0, -100)); await tester.pumpAndSettle(); await tester.tap(find.text('ADD STOP').first);
await tester.pumpAndSettle();
final stopSearchFields = find.widgetWithText(AddressAutocompleteField, 'Stop Address / Location *');
final stopInput2 = find.descendant(of: stopSearchFields.last, matching: find.byType(TextFormField));
await tester.enterText(stopInput2, 'Stop2');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
await tester.tap(find.text('Stop 2 Place').last);
await tester.pumpAndSettle();
await tester.tap(find.text('ADD STOP').last);
await tester.pumpAndSettle();
// Destination
final destSearchField = find.widgetWithText(AddressAutocompleteField, 'Destination Address / Location *');
final destInput = find.descendant(of: destSearchField, matching: find.byType(TextFormField));
await tester.enterText(destInput, 'Neels');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
await tester.tap(find.text('Neels Gap').last);
await tester.pumpAndSettle();
expect(fakeRouteProvider.lastCapturedStops, isNotNull);
final stops = fakeRouteProvider.lastCapturedStops!;
expect(stops.length, 4);
expect(stops[0].stopType, RouteStopType.origin);
expect(stops[1].stopType, RouteStopType.waypoint);
expect(stops[2].stopType, RouteStopType.waypoint);
expect(stops[3].stopType, RouteStopType.destination);
});
});
group('ROUTE-P2 REAL RESPONSIVE TESTS', () {
testWidgets('Pixel-like portrait (412x915)', (tester) async {
await tester.binding.setSurfaceSize(const Size(412, 915));
final fakeRouteProvider = FakeRouteProvider(
customSearchResults: [
const PlaceSearchResult(providerId: 'meetup_1', primaryText: 'Result 1', secondaryText: 'City 1', latitude: 34.1, longitude: -83.1),
],
);
final now = DateTime.now();
final profile = RiderProfile(uid: 'alice', handle: 'alice_apex', displayName: 'Alice Apex', createdAt: now, updatedAt: now);
final user = const AuthUser(uid: 'alice', email: 'alice@example.com');
final container = ProviderContainer(
overrides: [
currentUserProvider.overrideWithValue(user),
myRiderProfileProvider.overrideWith((ref) => Stream.value(profile)),
activeRouteCalculationProvider.overrideWithValue(fakeRouteProvider),
],
);
addTearDown(() {
tester.binding.setSurfaceSize(null);
container.dispose();
});
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
home: const CreateGroupRideScreen(),
),
),
);
await tester.pumpAndSettle();
final searchFields = find.widgetWithText(AddressAutocompleteField, 'Meeting Place Address / Location *');
final meetupInput = find.descendant(of: searchFields.first, matching: find.byType(TextFormField));
await tester.enterText(meetupInput, 'Start');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
FocusManager.instance.primaryFocus?.unfocus();
await tester.pumpAndSettle();
for (int i = 0; i < 20; i++) {
if (tester.any(find.text('ADD STOP').first)) {
final dy = tester.getCenter(find.text('ADD STOP').first).dy;
if (dy > 100 && dy < 300) break;
}
await tester.drag(find.byType(Scrollable).first, const Offset(0, -100));
await tester.pumpAndSettle();
}
await tester.tap(find.text('ADD STOP').first);
await tester.pumpAndSettle();
final stopFields = find.widgetWithText(AddressAutocompleteField, 'Stop Address / Location *');
final stopInput = find.descendant(of: stopFields.last, matching: find.byType(TextFormField));
await tester.enterText(stopInput, 'Stop1');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
await tester.tap(find.text('CANCEL'));
await tester.pumpAndSettle();
for (int i = 0; i < 20; i++) {
final destSearchField = find.widgetWithText(AddressAutocompleteField, 'Destination Address / Location *');
if (tester.any(destSearchField)) {
final dy = tester.getCenter(destSearchField).dy;
if (dy > 100 && dy < 300) break;
}
await tester.drag(find.byType(Scrollable).first, const Offset(0, -100));
await tester.pumpAndSettle();
}
final destSearchField = find.widgetWithText(AddressAutocompleteField, 'Destination Address / Location *');
final destInput = find.descendant(of: destSearchField, matching: find.byType(TextFormField));
await tester.enterText(destInput, 'Dest');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('Short landscape (915x412)', (tester) async {
await tester.binding.setSurfaceSize(const Size(915, 412));
final fakeRouteProvider = FakeRouteProvider(
customSearchResults: [
const PlaceSearchResult(providerId: 'meetup_1', primaryText: 'Result 1', secondaryText: 'City 1', latitude: 34.1, longitude: -83.1),
],
);
final now = DateTime.now();
final profile = RiderProfile(uid: 'alice', handle: 'alice_apex', displayName: 'Alice Apex', createdAt: now, updatedAt: now);
final user = const AuthUser(uid: 'alice', email: 'alice@example.com');
final container = ProviderContainer(
overrides: [
currentUserProvider.overrideWithValue(user),
myRiderProfileProvider.overrideWith((ref) => Stream.value(profile)),
activeRouteCalculationProvider.overrideWithValue(fakeRouteProvider),
],
);
addTearDown(() {
tester.binding.setSurfaceSize(null);
container.dispose();
});
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
home: const CreateGroupRideScreen(),
),
),
);
await tester.pumpAndSettle();
final searchFields = find.widgetWithText(AddressAutocompleteField, 'Meeting Place Address / Location *');
final meetupInput = find.descendant(of: searchFields.first, matching: find.byType(TextFormField));
await tester.enterText(meetupInput, 'Start');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
FocusManager.instance.primaryFocus?.unfocus();
await tester.pumpAndSettle();
for (int i = 0; i < 20; i++) {
if (tester.any(find.text('ADD STOP').first)) {
final dy = tester.getCenter(find.text('ADD STOP').first).dy;
if (dy > 100 && dy < 300) break;
}
await tester.drag(find.byType(Scrollable).first, const Offset(0, -100));
await tester.pumpAndSettle();
}
await tester.tap(find.text('ADD STOP').first);
await tester.pumpAndSettle();
final stopFields = find.widgetWithText(AddressAutocompleteField, 'Stop Address / Location *');
final stopInput = find.descendant(of: stopFields.last, matching: find.byType(TextFormField));
await tester.enterText(stopInput, 'Stop1');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
await tester.tap(find.text('CANCEL'));
await tester.pumpAndSettle();
for (int i = 0; i < 20; i++) {
final destSearchField = find.widgetWithText(AddressAutocompleteField, 'Destination Address / Location *');
if (tester.any(destSearchField)) {
final dy = tester.getCenter(destSearchField).dy;
if (dy > 100 && dy < 300) break;
}
await tester.drag(find.byType(Scrollable).first, const Offset(0, -100));
await tester.pumpAndSettle();
}
final destSearchField = find.widgetWithText(AddressAutocompleteField, 'Destination Address / Location *');
final destInput = find.descendant(of: destSearchField, matching: find.byType(TextFormField));
await tester.enterText(destInput, 'Dest');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('ROUTE RACE - In-flight calculation is discarded if sequence advances', (tester) async {
await tester.binding.setSurfaceSize(const Size(800, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
Completer<void>? calc1Completer;
final fakeRouteProvider = FakeRouteProvider(
customSearchResults: [
const PlaceSearchResult(providerId: 'meetup_1', primaryText: 'Start', secondaryText: 'City 1', latitude: 34.1, longitude: -83.1),
const PlaceSearchResult(providerId: 'dest_1', primaryText: 'Dest1', secondaryText: 'City 2', latitude: 34.2, longitude: -83.2),
],
onCalculate: (count) async {
print('ON CALCULATE CALLED WITH COUNT: '); if (count == 0) {
calc1Completer = Completer<void>();
await calc1Completer!.future;
}
},
);
final container = ProviderContainer(
overrides: [
activeRouteCalculationProvider.overrideWithValue(fakeRouteProvider),
currentUserProvider.overrideWithValue(const AuthUser(uid: 'alice', email: 'alice@example.com')),
],
);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const MaterialApp(home: CreateGroupRideScreen()),
),
);
await tester.pumpAndSettle();
// Step 1: Add Meetup
final meetupField = find.widgetWithText(AddressAutocompleteField, 'Meeting Place Address / Location *');
await tester.enterText(find.descendant(of: meetupField, matching: find.byType(TextFormField)), 'Start');
await tester.pumpAndSettle();
await tester.tap(find.text('Start').last);
await tester.pumpAndSettle();
// Step 2: Add Destination
final destField = find.widgetWithText(AddressAutocompleteField, 'Destination Address / Location *');
await tester.enterText(find.descendant(of: destField, matching: find.byType(TextFormField)), 'Dest1');
await tester.pumpAndSettle();
// Tap Dest1 -> triggers Calculation 0 (which stalls because count == 0)
await tester.tap(find.text('Dest1').last);
await tester.pump(); // Start calc
// Step 3: While Calc 0 is stalled, clear the destination!
// This triggers sequence increment and an early return.
await tester.enterText(find.descendant(of: destField, matching: find.byType(TextFormField)), '');
// Tap the 'clear' icon if there is one, or just unfocus
FocusManager.instance.primaryFocus?.unfocus();
await tester.pumpAndSettle();
// Step 4: Now finish Calc 0!
calc1Completer?.complete();
await tester.pumpAndSettle();
// Verification: The stale Calc 0 should NOT be shown!
// The UI should show no distance label.
expect(find.textContaining('miles'), findsNothing);
expect(find.textContaining('km'), findsNothing);
});
});
}

View File

@ -1,14 +1,24 @@
import 'package:motorcycle_dash/services/map/map_provider_interface.dart';
import 'package:motorcycle_dash/services/routes/route_models.dart';
import 'package:motorcycle_dash/services/routes/route_provider.dart';
import 'dart:async';
class FakeRouteProvider implements RouteProvider {
final bool shouldFail;
final bool shouldTimeout;
final List<PlaceSearchResult>? customSearchResults;
final ResolvedPlace? customResolvedPlace;
final Future<void> Function(int count)? onCalculate;
int _calcCount = 0;
List<RouteStop>? lastCapturedStops;
FakeRouteProvider({
this.shouldFail = false,
this.shouldTimeout = false,
this.customSearchResults,
this.customResolvedPlace,
this.onCalculate,
});
@override
@ -21,6 +31,12 @@ class FakeRouteProvider implements RouteProvider {
bool avoidTolls = false,
bool avoidFerries = false,
}) async {
lastCapturedStops = stops;
if (onCalculate != null) {
await onCalculate!(_calcCount++);
}
if (shouldTimeout) {
await Future.delayed(const Duration(seconds: 2));
throw RouteProviderException('providerTimeout', 'Fake timeout');
@ -54,24 +70,17 @@ class FakeRouteProvider implements RouteProvider {
);
}
@override
Future<ResolvedPlace> resolvePlace(String placeId) async {
if (shouldFail) throw RouteProviderException('placeNotFound', 'Fake not found');
return ResolvedPlace(
providerId: placeId,
displayName: 'Fake Place',
latitude: 0.0,
longitude: 0.0,
attribution: 'Fake Attribution',
);
}
@override
Future<List<PlaceSearchResult>> searchPlaces(
String query, {
AppLatLng? nearLocation,
}) async {
if (shouldFail) throw RouteProviderException('placeSearchUnavailable', 'Fake search error');
if (shouldFail) {
throw RouteProviderException('placeSearchUnavailable', 'Fake search error');
}
if (customSearchResults != null) return customSearchResults!;
if (query.length < 2) return [];
return [
@ -83,4 +92,19 @@ class FakeRouteProvider implements RouteProvider {
),
];
}
@override
Future<ResolvedPlace> resolvePlace(String providerPlaceId) async {
if (shouldFail) {
throw RouteProviderException('placeNotFound', 'Fake not found');
}
if (customResolvedPlace != null) return customResolvedPlace!;
return ResolvedPlace(
providerId: providerPlaceId,
displayName: 'Fake Place',
latitude: 0.0,
longitude: 0.0,
attribution: 'Fake Attribution',
);
}
}

View File

@ -0,0 +1,299 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/features/group_rides/live_group_ride_screen.dart';
import 'package:motorcycle_dash/services/auth/auth_user.dart';
import 'package:motorcycle_dash/services/auth/auth_providers.dart';
import 'package:motorcycle_dash/services/group_rides/group_ride_models.dart';
import 'package:motorcycle_dash/services/group_rides/group_ride_providers.dart';
import 'package:motorcycle_dash/services/group_rides/group_ride_service.dart';
class _FakeGroupRideService extends GroupRideService {
String? lastCallerUid;
String? lastGroupRideId;
GroupRideStatus? lastNewStatus;
int updateRideStatusCallCount = 0;
@override
Future<void> updateRideStatus({
required String callerUid,
required String groupRideId,
required GroupRideStatus newStatus,
}) async {
lastCallerUid = callerUid;
lastGroupRideId = groupRideId;
lastNewStatus = newStatus;
updateRideStatusCallCount++;
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('LiveGroupRideScreen - Organizer End Ride Action Tests', () {
final now = DateTime.now();
final underwayRide = GroupRide(
id: 'ride_test_123',
organizerUid: 'organizer_alice',
organizerHandle: 'alice_apex',
organizerDisplayName: 'Alice Apex',
title: 'Sunday Morning Twisties',
status: GroupRideStatus.underway,
scheduledStartAt: now,
departureAt: now,
participantCount: 2,
meetingPoint: const GroupRideLocation(
latitude: 34.0,
longitude: -84.0,
displayName: 'Start Point',
),
destination: const GroupRideLocation(
latitude: 34.5,
longitude: -84.5,
displayName: 'End Point',
),
stops: const [],
createdAt: now,
updatedAt: now,
);
final completedRide = underwayRide.copyWith(
status: GroupRideStatus.completed,
);
final participants = [
GroupRideParticipant(
userId: 'organizer_alice',
role: GroupRideRole.lead,
status: ParticipantStatus.accepted,
riderHandle: 'alice_apex',
riderDisplayName: 'Alice Apex',
createdAt: now,
updatedAt: now,
),
GroupRideParticipant(
userId: 'rider_bob',
role: GroupRideRole.participant,
status: ParticipantStatus.accepted,
riderHandle: 'bob_rider',
riderDisplayName: 'Bob Rider',
createdAt: now,
updatedAt: now,
),
];
testWidgets('organizer sees "Ride Options" popup menu with "End Ride" when underway',
(tester) async {
final fakeService = _FakeGroupRideService();
const user = AuthUser(
uid: 'organizer_alice',
email: 'alice@example.com',
isAnonymous: false,
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWithValue(user),
groupRideServiceProvider.overrideWithValue(fakeService),
groupRideStreamProvider('ride_test_123')
.overrideWith((ref) => Stream.value(underwayRide)),
groupRideParticipantsProvider('ride_test_123')
.overrideWith((ref) => Stream.value(participants)),
groupRideLivePresenceProvider('ride_test_123')
.overrideWith((ref) => Stream.value([])),
groupRideLiveSharingAllowedProvider.overrideWith((ref) => true),
],
child: const MaterialApp(
home: LiveGroupRideScreen(rideId: 'ride_test_123'),
),
),
);
await tester.pumpAndSettle();
// Ride Options menu button should be visible
final rideOptionsFinder = find.byTooltip('Ride Options');
expect(rideOptionsFinder, findsOneWidget);
// Open the popup menu
await tester.tap(rideOptionsFinder);
await tester.pumpAndSettle();
// "End Ride" menu item should be visible
expect(find.text('End Ride'), findsOneWidget);
});
testWidgets('non-organizer participant does NOT see "Ride Options" popup menu',
(tester) async {
final fakeService = _FakeGroupRideService();
const nonOrganizerUser = AuthUser(
uid: 'rider_bob',
email: 'bob@example.com',
isAnonymous: false,
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWithValue(nonOrganizerUser),
groupRideServiceProvider.overrideWithValue(fakeService),
groupRideStreamProvider('ride_test_123')
.overrideWith((ref) => Stream.value(underwayRide)),
groupRideParticipantsProvider('ride_test_123')
.overrideWith((ref) => Stream.value(participants)),
groupRideLivePresenceProvider('ride_test_123')
.overrideWith((ref) => Stream.value([])),
groupRideLiveSharingAllowedProvider.overrideWith((ref) => true),
],
child: const MaterialApp(
home: LiveGroupRideScreen(rideId: 'ride_test_123'),
),
),
);
await tester.pumpAndSettle();
// Non-organizer should NOT see Ride Options
expect(find.byTooltip('Ride Options'), findsNothing);
expect(find.text('End Ride'), findsNothing);
});
testWidgets('organizer does NOT see "Ride Options" popup menu when ride is completed',
(tester) async {
final fakeService = _FakeGroupRideService();
const user = AuthUser(
uid: 'organizer_alice',
email: 'alice@example.com',
isAnonymous: false,
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWithValue(user),
groupRideServiceProvider.overrideWithValue(fakeService),
groupRideStreamProvider('ride_test_123')
.overrideWith((ref) => Stream.value(completedRide)),
groupRideParticipantsProvider('ride_test_123')
.overrideWith((ref) => Stream.value(participants)),
groupRideLivePresenceProvider('ride_test_123')
.overrideWith((ref) => Stream.value([])),
groupRideLiveSharingAllowedProvider.overrideWith((ref) => true),
],
child: const MaterialApp(
home: LiveGroupRideScreen(rideId: 'ride_test_123'),
),
),
);
await tester.pumpAndSettle();
expect(find.byTooltip('Ride Options'), findsNothing);
});
testWidgets('tapping End Ride shows confirmation dialog and CANCEL dismisses without state change',
(tester) async {
final fakeService = _FakeGroupRideService();
const user = AuthUser(
uid: 'organizer_alice',
email: 'alice@example.com',
isAnonymous: false,
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWithValue(user),
groupRideServiceProvider.overrideWithValue(fakeService),
groupRideStreamProvider('ride_test_123')
.overrideWith((ref) => Stream.value(underwayRide)),
groupRideParticipantsProvider('ride_test_123')
.overrideWith((ref) => Stream.value(participants)),
groupRideLivePresenceProvider('ride_test_123')
.overrideWith((ref) => Stream.value([])),
groupRideLiveSharingAllowedProvider.overrideWith((ref) => true),
],
child: const MaterialApp(
home: LiveGroupRideScreen(rideId: 'ride_test_123'),
),
),
);
await tester.pumpAndSettle();
// Tap Ride Options
await tester.tap(find.byTooltip('Ride Options'));
await tester.pumpAndSettle();
// Tap End Ride
await tester.tap(find.text('End Ride'));
await tester.pumpAndSettle();
// Dialog should be displayed with expected title and copy
expect(find.text('End this group ride?'), findsOneWidget);
expect(
find.text('Ending the ride will stop live location sharing and mark the Group Ride as completed.'),
findsOneWidget,
);
// Tap CANCEL
await tester.tap(find.text('CANCEL'));
await tester.pumpAndSettle();
// Dialog dismissed, no service call made
expect(find.text('End this group ride?'), findsNothing);
expect(fakeService.updateRideStatusCallCount, equals(0));
});
testWidgets('tapping END RIDE in confirmation dialog triggers status update to completed',
(tester) async {
final fakeService = _FakeGroupRideService();
const user = AuthUser(
uid: 'organizer_alice',
email: 'alice@example.com',
isAnonymous: false,
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWithValue(user),
groupRideServiceProvider.overrideWithValue(fakeService),
groupRideStreamProvider('ride_test_123')
.overrideWith((ref) => Stream.value(underwayRide)),
groupRideParticipantsProvider('ride_test_123')
.overrideWith((ref) => Stream.value(participants)),
groupRideLivePresenceProvider('ride_test_123')
.overrideWith((ref) => Stream.value([])),
groupRideLiveSharingAllowedProvider.overrideWith((ref) => true),
],
child: const MaterialApp(
home: LiveGroupRideScreen(rideId: 'ride_test_123'),
),
),
);
await tester.pumpAndSettle();
// Tap Ride Options
await tester.tap(find.byTooltip('Ride Options'));
await tester.pumpAndSettle();
// Tap End Ride menu item
await tester.tap(find.text('End Ride'));
await tester.pumpAndSettle();
// Tap END RIDE button in dialog
await tester.tap(find.text('END RIDE'));
await tester.pumpAndSettle();
// Verify updateRideStatus was invoked with correct arguments
expect(fakeService.updateRideStatusCallCount, equals(1));
expect(fakeService.lastCallerUid, equals('organizer_alice'));
expect(fakeService.lastGroupRideId, equals('ride_test_123'));
expect(fakeService.lastNewStatus, equals(GroupRideStatus.completed));
});
});
}