feat: add real route planning and group ride navigation experience
This commit is contained in:
parent
9958a8a600
commit
92ce23cdb7
File diff suppressed because it is too large
Load Diff
|
|
@ -476,14 +476,35 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
if (isOrganizer)
|
if (isOrganizer && ride.status == GroupRideStatus.underway)
|
||||||
IconButton(
|
PopupMenuButton<String>(
|
||||||
icon: Icon(
|
icon: const Icon(Icons.more_vert, color: Colors.white70),
|
||||||
Icons.stop_circle_outlined,
|
tooltip: 'Ride Options',
|
||||||
color: colors.raceRed,
|
color: colors.surfaceContainerHigh,
|
||||||
),
|
onSelected: (val) {
|
||||||
tooltip: 'End Ride',
|
if (val == 'end_ride') {
|
||||||
onPressed: () => _confirmEndRide(context, ride, currentUid, colors),
|
_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(
|
IconButton(
|
||||||
icon: Icon(Icons.people_outline, color: colors.cyberCyan),
|
icon: Icon(Icons.people_outline, color: colors.cyberCyan),
|
||||||
|
|
@ -1441,11 +1462,11 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
backgroundColor: colors.surfaceContainerHigh,
|
backgroundColor: colors.surfaceContainerHigh,
|
||||||
title: const Text(
|
title: const Text(
|
||||||
'End Group Ride?',
|
'End this group ride?',
|
||||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
content: const Text(
|
content: const Text(
|
||||||
'This will conclude the group ride for all participants and terminate live location sharing.',
|
'Ending the ride will stop live location sharing and mark the Group Ride as completed.',
|
||||||
style: TextStyle(color: Colors.white70),
|
style: TextStyle(color: Colors.white70),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
|
@ -1469,9 +1490,6 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
||||||
groupRideId: ride.id,
|
groupRideId: ride.id,
|
||||||
newStatus: GroupRideStatus.completed,
|
newStatus: GroupRideStatus.completed,
|
||||||
);
|
);
|
||||||
if (context.mounted) {
|
|
||||||
context.pop();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
child: const Text('END RIDE'),
|
child: const Text('END RIDE'),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import '../../services/routes/navigation_handoff.dart';
|
||||||
import '../../services/routes/route_models.dart';
|
import '../../services/routes/route_models.dart';
|
||||||
import '../../services/routes/route_providers.dart';
|
import '../../services/routes/route_providers.dart';
|
||||||
import '../../services/routes/route_service.dart';
|
import '../../services/routes/route_service.dart';
|
||||||
|
import 'widgets/address_autocomplete_field.dart';
|
||||||
|
|
||||||
class RoutePlannerScreen extends ConsumerStatefulWidget {
|
class RoutePlannerScreen extends ConsumerStatefulWidget {
|
||||||
final String? initialRouteId;
|
final String? initialRouteId;
|
||||||
|
|
@ -162,6 +163,34 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: BorderSide(color: colors.cyberCyan),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12, vertical: 8),
|
||||||
|
minimumSize: const Size(double.infinity, 36),
|
||||||
|
),
|
||||||
|
icon:
|
||||||
|
Icon(Icons.search, size: 16, color: colors.cyberCyan),
|
||||||
|
label: Text(
|
||||||
|
'SEARCH ADDRESS / POI',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colors.cyberCyan,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: () => _openAddressSearchForStop(
|
||||||
|
context,
|
||||||
|
colors,
|
||||||
|
setDialogState,
|
||||||
|
nameCtrl,
|
||||||
|
latCtrl,
|
||||||
|
lngCtrl,
|
||||||
|
notesCtrl,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
controller: nameCtrl,
|
controller: nameCtrl,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: const TextStyle(color: Colors.white),
|
||||||
|
|
@ -170,6 +199,20 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
labelStyle: const TextStyle(color: Colors.white60),
|
labelStyle: const TextStyle(color: Colors.white60),
|
||||||
hintText: 'e.g. Skyline Summit, Fuel Stop',
|
hintText: 'e.g. Skyline Summit, Fuel Stop',
|
||||||
hintStyle: const TextStyle(color: Colors.white24),
|
hintStyle: const TextStyle(color: Colors.white24),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(Icons.search,
|
||||||
|
color: colors.cyberCyan, size: 20),
|
||||||
|
tooltip: 'Search address',
|
||||||
|
onPressed: () => _openAddressSearchForStop(
|
||||||
|
context,
|
||||||
|
colors,
|
||||||
|
setDialogState,
|
||||||
|
nameCtrl,
|
||||||
|
latCtrl,
|
||||||
|
lngCtrl,
|
||||||
|
notesCtrl,
|
||||||
|
),
|
||||||
|
),
|
||||||
enabledBorder: UnderlineInputBorder(
|
enabledBorder: UnderlineInputBorder(
|
||||||
borderSide: BorderSide(color: colors.outlineVariant),
|
borderSide: BorderSide(color: colors.outlineVariant),
|
||||||
),
|
),
|
||||||
|
|
@ -353,6 +396,66 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openAddressSearchForStop(
|
||||||
|
BuildContext context,
|
||||||
|
MotorSignalThemeColors colors,
|
||||||
|
void Function(void Function()) setDialogState,
|
||||||
|
TextEditingController nameCtrl,
|
||||||
|
TextEditingController latCtrl,
|
||||||
|
TextEditingController lngCtrl,
|
||||||
|
TextEditingController notesCtrl,
|
||||||
|
) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: colors.surfaceContainerHigh,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
builder: (sheetCtx) => Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
top: 16,
|
||||||
|
bottom: MediaQuery.of(sheetCtx).viewInsets.bottom + 16,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'SEARCH ADDRESS OR PLACE',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
AddressAutocompleteField(
|
||||||
|
labelText: 'Search location',
|
||||||
|
autofocus: true,
|
||||||
|
onPlaceResolved: (place) {
|
||||||
|
if (place != null) {
|
||||||
|
setDialogState(() {
|
||||||
|
nameCtrl.text = place.displayName;
|
||||||
|
latCtrl.text = place.latitude.toStringAsFixed(6);
|
||||||
|
lngCtrl.text = place.longitude.toStringAsFixed(6);
|
||||||
|
if (notesCtrl.text.isEmpty &&
|
||||||
|
place.formattedAddress != null) {
|
||||||
|
notesCtrl.text = place.formattedAddress!;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Navigator.pop(sheetCtx);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _saveRoute() async {
|
Future<void> _saveRoute() async {
|
||||||
final title = _titleController.text.trim();
|
final title = _titleController.text.trim();
|
||||||
if (title.length < 3 || title.length > 80) {
|
if (title.length < 3 || title.length > 80) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,495 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../../core/theme/theme_extensions.dart';
|
||||||
|
import '../../../services/map/map_provider_interface.dart';
|
||||||
|
import '../../../services/routes/route_provider.dart';
|
||||||
|
import '../../../services/routes/route_providers.dart';
|
||||||
|
|
||||||
|
/// Reusable address and place autocomplete field for MotorSignal.
|
||||||
|
///
|
||||||
|
/// Integrates with [RouteProvider] (Stadia Maps / Pelias) to provide:
|
||||||
|
/// - 350ms debounced search
|
||||||
|
/// - Sequence-guarded asynchronous responses to prevent stale data
|
||||||
|
/// - Immediate resolution when coordinates are present in search results
|
||||||
|
/// - Invalidation of previous resolution upon text modification
|
||||||
|
/// - Accessible suggestions list with loading, no-results, and error states
|
||||||
|
class AddressAutocompleteField extends ConsumerStatefulWidget {
|
||||||
|
final String labelText;
|
||||||
|
final String? hintText;
|
||||||
|
final ResolvedPlace? initialPlace;
|
||||||
|
final String? initialQuery;
|
||||||
|
final ValueChanged<ResolvedPlace?> onPlaceResolved;
|
||||||
|
final AppLatLng? nearLocation;
|
||||||
|
final String? Function(String?)? validator;
|
||||||
|
final bool enabled;
|
||||||
|
final bool autofocus;
|
||||||
|
final FocusNode? focusNode;
|
||||||
|
final TextEditingController? controller;
|
||||||
|
final Widget? prefixIcon;
|
||||||
|
final bool showAttribution;
|
||||||
|
final VoidCallback? onClear;
|
||||||
|
final bool isRequired;
|
||||||
|
|
||||||
|
const AddressAutocompleteField({
|
||||||
|
super.key,
|
||||||
|
required this.labelText,
|
||||||
|
this.hintText,
|
||||||
|
this.initialPlace,
|
||||||
|
this.initialQuery,
|
||||||
|
required this.onPlaceResolved,
|
||||||
|
this.nearLocation,
|
||||||
|
this.validator,
|
||||||
|
this.enabled = true,
|
||||||
|
this.autofocus = false,
|
||||||
|
this.focusNode,
|
||||||
|
this.controller,
|
||||||
|
this.prefixIcon,
|
||||||
|
this.showAttribution = true,
|
||||||
|
this.onClear,
|
||||||
|
this.isRequired = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AddressAutocompleteField> createState() =>
|
||||||
|
_AddressAutocompleteFieldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddressAutocompleteFieldState
|
||||||
|
extends ConsumerState<AddressAutocompleteField> {
|
||||||
|
late final TextEditingController _controller;
|
||||||
|
late final FocusNode _focusNode;
|
||||||
|
bool _internalController = false;
|
||||||
|
bool _internalFocusNode = false;
|
||||||
|
|
||||||
|
ResolvedPlace? _resolvedPlace;
|
||||||
|
List<PlaceSearchResult> _suggestions = const [];
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _isResolving = false;
|
||||||
|
String? _errorMessage;
|
||||||
|
Timer? _debounceTimer;
|
||||||
|
int _searchSequence = 0;
|
||||||
|
bool _showDropdown = false;
|
||||||
|
bool _hasSearched = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.controller != null) {
|
||||||
|
_controller = widget.controller!;
|
||||||
|
} else {
|
||||||
|
_controller = TextEditingController(
|
||||||
|
text: widget.initialPlace?.displayName ?? widget.initialQuery ?? '',
|
||||||
|
);
|
||||||
|
_internalController = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.focusNode != null) {
|
||||||
|
_focusNode = widget.focusNode!;
|
||||||
|
} else {
|
||||||
|
_focusNode = FocusNode();
|
||||||
|
_internalFocusNode = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_resolvedPlace = widget.initialPlace;
|
||||||
|
|
||||||
|
_focusNode.addListener(_handleFocusChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant AddressAutocompleteField oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.initialPlace != oldWidget.initialPlace) {
|
||||||
|
_resolvedPlace = widget.initialPlace;
|
||||||
|
final newText = widget.initialPlace?.displayName ?? '';
|
||||||
|
if (_controller.text != newText) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted && _controller.text != newText) {
|
||||||
|
_controller.text = newText;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
_focusNode.removeListener(_handleFocusChange);
|
||||||
|
if (_internalFocusNode) {
|
||||||
|
_focusNode.dispose();
|
||||||
|
}
|
||||||
|
if (_internalController) {
|
||||||
|
_controller.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleFocusChange() {
|
||||||
|
if (!_focusNode.hasFocus) {
|
||||||
|
// Delay closing dropdown slightly so tap on suggestion can register
|
||||||
|
Future.delayed(const Duration(milliseconds: 200), () {
|
||||||
|
if (mounted && !_focusNode.hasFocus) {
|
||||||
|
setState(() {
|
||||||
|
_showDropdown = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTextChanged(String text) {
|
||||||
|
// If the text was previously resolved, modifying text invalidates the resolution
|
||||||
|
if (_resolvedPlace != null) {
|
||||||
|
_resolvedPlace = null;
|
||||||
|
widget.onPlaceResolved(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
|
||||||
|
final trimmed = text.trim();
|
||||||
|
if (trimmed.length < 2) {
|
||||||
|
setState(() {
|
||||||
|
_suggestions = const [];
|
||||||
|
_isLoading = false;
|
||||||
|
_showDropdown = false;
|
||||||
|
_errorMessage = null;
|
||||||
|
_hasSearched = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_debounceTimer = Timer(const Duration(milliseconds: 350), () {
|
||||||
|
_performSearch(trimmed);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _performSearch(String query) async {
|
||||||
|
final seq = ++_searchSequence;
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
_hasSearched = true;
|
||||||
|
_showDropdown = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final provider = ref.read(activeRouteCalculationProvider);
|
||||||
|
final results = await provider.searchPlaces(
|
||||||
|
query,
|
||||||
|
nearLocation: widget.nearLocation,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted || seq != _searchSequence) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_suggestions = results;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted || seq != _searchSequence) return;
|
||||||
|
|
||||||
|
final msg = e is RouteProviderException
|
||||||
|
? e.message
|
||||||
|
: e.toString()
|
||||||
|
.replaceAll('RouteProviderException: ', '')
|
||||||
|
.replaceAll('Exception: ', '');
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_errorMessage = msg.isNotEmpty ? msg : 'Unable to search addresses.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _selectSuggestion(PlaceSearchResult suggestion) async {
|
||||||
|
// If coordinates are already present in search result, resolve immediately without secondary call
|
||||||
|
if (suggestion.hasCoordinates) {
|
||||||
|
final place = suggestion.toResolvedPlace()!;
|
||||||
|
_applyResolvedPlace(place);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: resolve via provider
|
||||||
|
setState(() {
|
||||||
|
_isResolving = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final provider = ref.read(activeRouteCalculationProvider);
|
||||||
|
final place = await provider.resolvePlace(suggestion.providerId);
|
||||||
|
if (!mounted) return;
|
||||||
|
_applyResolvedPlace(place);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isResolving = false;
|
||||||
|
_errorMessage = 'Failed to resolve location coordinates.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyResolvedPlace(ResolvedPlace place) {
|
||||||
|
_resolvedPlace = place;
|
||||||
|
_controller.text = place.displayName;
|
||||||
|
setState(() {
|
||||||
|
_isResolving = false;
|
||||||
|
_suggestions = const [];
|
||||||
|
_showDropdown = false;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
_focusNode.unfocus();
|
||||||
|
widget.onPlaceResolved(place);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clear() {
|
||||||
|
_controller.clear();
|
||||||
|
_resolvedPlace = null;
|
||||||
|
_suggestions = const [];
|
||||||
|
_showDropdown = false;
|
||||||
|
_errorMessage = null;
|
||||||
|
_hasSearched = false;
|
||||||
|
setState(() {});
|
||||||
|
widget.onPlaceResolved(null);
|
||||||
|
widget.onClear?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.motorColors;
|
||||||
|
final isResolved = _resolvedPlace != null;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
controller: _controller,
|
||||||
|
focusNode: _focusNode,
|
||||||
|
enabled: widget.enabled && !_isResolving,
|
||||||
|
autofocus: widget.autofocus,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 14),
|
||||||
|
onChanged: _onTextChanged,
|
||||||
|
validator: (val) {
|
||||||
|
if (widget.isRequired && (val == null || val.trim().isEmpty)) {
|
||||||
|
return 'This location is required';
|
||||||
|
}
|
||||||
|
if (widget.isRequired && _resolvedPlace == null) {
|
||||||
|
return 'Please select a valid address from the suggestions';
|
||||||
|
}
|
||||||
|
if (widget.validator != null) {
|
||||||
|
return widget.validator!(val);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: widget.labelText,
|
||||||
|
hintText: widget.hintText ?? 'Type address or place name...',
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: isResolved ? colors.launchGreen : Colors.white70,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
hintStyle: const TextStyle(color: Colors.white24, fontSize: 13),
|
||||||
|
filled: true,
|
||||||
|
fillColor: colors.surfaceContainerHigh,
|
||||||
|
prefixIcon: widget.prefixIcon ??
|
||||||
|
(isResolved
|
||||||
|
? Icon(Icons.check_circle,
|
||||||
|
color: colors.launchGreen, size: 20)
|
||||||
|
: Icon(Icons.search, color: colors.cyberCyan, size: 20)),
|
||||||
|
suffixIcon: _isResolving
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: colors.cyberCyan,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: _controller.text.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear,
|
||||||
|
size: 18, color: Colors.white54),
|
||||||
|
onPressed: _clear,
|
||||||
|
tooltip: 'Clear location',
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: isResolved ? colors.launchGreen : colors.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: isResolved
|
||||||
|
? colors.launchGreen.withValues(alpha: 0.6)
|
||||||
|
: colors.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: isResolved ? colors.launchGreen : colors.cyberCyan,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
errorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(color: colors.raceRed),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_showDropdown) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_buildDropdownPanel(colors),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDropdownPanel(MotorSignalThemeColors colors) {
|
||||||
|
return Material(
|
||||||
|
color: colors.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
elevation: 4,
|
||||||
|
child: Container(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 200),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: colors.outlineVariant),
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (_isLoading)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: colors.cyberCyan,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Text(
|
||||||
|
'Searching addresses...',
|
||||||
|
style: TextStyle(color: Colors.white70, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (_errorMessage != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.warning_amber_rounded,
|
||||||
|
color: colors.warningAmber, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_errorMessage!,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => _performSearch(_controller.text.trim()),
|
||||||
|
child: Text(
|
||||||
|
'RETRY',
|
||||||
|
style:
|
||||||
|
TextStyle(color: colors.cyberCyan, fontSize: 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (_hasSearched && _suggestions.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.location_off, color: Colors.white38, size: 16),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'No matching addresses found',
|
||||||
|
style: TextStyle(color: Colors.white54, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
for (int i = 0; i < _suggestions.length; i++) ...[
|
||||||
|
if (i > 0) const Divider(height: 1, color: Colors.white10),
|
||||||
|
ListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||||
|
leading: Icon(
|
||||||
|
Icons.place_outlined,
|
||||||
|
color: colors.cyberCyan,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
_suggestions[i].primaryText,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: _suggestions[i].secondaryText.isNotEmpty
|
||||||
|
? Text(
|
||||||
|
_suggestions[i].secondaryText,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white54,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: () => _selectSuggestion(_suggestions[i]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (widget.showAttribution)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
color: colors.surfaceContainer,
|
||||||
|
child: const Text(
|
||||||
|
'Search by Stadia Maps / Pelias • © OpenStreetMap contributors',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white38,
|
||||||
|
fontSize: 9,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,270 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:motorcycle_dash/features/routes/widgets/address_autocomplete_field.dart';
|
||||||
|
import 'package:motorcycle_dash/services/routes/route_provider.dart';
|
||||||
|
import 'package:motorcycle_dash/services/routes/route_providers.dart';
|
||||||
|
import 'fake_route_provider.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('AddressAutocompleteField', () {
|
||||||
|
testWidgets('debounces input and displays suggestions', (tester) async {
|
||||||
|
final fake = FakeRouteProvider(
|
||||||
|
customSearchResults: [
|
||||||
|
const PlaceSearchResult(
|
||||||
|
providerId: 'p_1',
|
||||||
|
primaryText: 'Dahlonega Square',
|
||||||
|
secondaryText: 'Dahlonega, GA',
|
||||||
|
latitude: 34.5327,
|
||||||
|
longitude: -83.9849,
|
||||||
|
formattedAddress: '1 Public Sq, Dahlonega, GA',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
ResolvedPlace? resolved;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: AddressAutocompleteField(
|
||||||
|
labelText: 'Meetup Location',
|
||||||
|
onPlaceResolved: (p) => resolved = p,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Enter 1 char -> should not search
|
||||||
|
await tester.enterText(find.byType(TextField), 'D');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
expect(find.text('Dahlonega Square'), findsNothing);
|
||||||
|
|
||||||
|
// Enter 3 chars -> debounce 350ms -> suggestions appear
|
||||||
|
await tester.enterText(find.byType(TextField), 'Dah');
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
// Mid-debounce -> suggestions not yet displayed
|
||||||
|
expect(find.text('Dahlonega Square'), findsNothing);
|
||||||
|
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Suggestions visible
|
||||||
|
expect(find.text('Dahlonega Square'), findsOneWidget);
|
||||||
|
expect(find.text('Dahlonega, GA'), findsOneWidget);
|
||||||
|
expect(resolved, isNull);
|
||||||
|
|
||||||
|
// Tap suggestion to select
|
||||||
|
await tester.tap(find.text('Dahlonega Square'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Place is now resolved
|
||||||
|
expect(resolved, isNotNull);
|
||||||
|
expect(resolved!.displayName, 'Dahlonega Square');
|
||||||
|
expect(resolved!.latitude, 34.5327);
|
||||||
|
expect(resolved!.longitude, -83.9849);
|
||||||
|
expect(find.byIcon(Icons.check_circle), findsWidgets);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('editing text invalidates prior resolution', (tester) async {
|
||||||
|
final fake = FakeRouteProvider(
|
||||||
|
customSearchResults: [
|
||||||
|
const PlaceSearchResult(
|
||||||
|
providerId: 'p_1',
|
||||||
|
primaryText: 'Blood Mountain',
|
||||||
|
secondaryText: 'Blairsville, GA',
|
||||||
|
latitude: 34.7398,
|
||||||
|
longitude: -83.9372,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
ResolvedPlace? resolved;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: AddressAutocompleteField(
|
||||||
|
labelText: 'Destination',
|
||||||
|
onPlaceResolved: (p) => resolved = p,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Type and select
|
||||||
|
await tester.enterText(find.byType(TextField), 'Blood');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Blood Mountain'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(resolved, isNotNull);
|
||||||
|
|
||||||
|
// User edits the text -> resolution MUST be invalidated
|
||||||
|
await tester.enterText(find.byType(TextField), 'Blood Mountain modified');
|
||||||
|
expect(resolved, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('displays no-results state when search yields no matches', (tester) async {
|
||||||
|
final fake = FakeRouteProvider(
|
||||||
|
customSearchResults: const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: AddressAutocompleteField(
|
||||||
|
labelText: 'Stop Location',
|
||||||
|
onPlaceResolved: (_) {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), 'Nowhereville');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('No matching addresses found'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('displays error and retry when provider fails', (tester) async {
|
||||||
|
final fake = FakeRouteProvider(shouldFail: true);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: AddressAutocompleteField(
|
||||||
|
labelText: 'Meetup Location',
|
||||||
|
onPlaceResolved: (_) {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), 'ErrorPlace');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Fake search error'), findsOneWidget);
|
||||||
|
expect(find.text('RETRY'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('clearing text resets state and invalidates resolution', (tester) async {
|
||||||
|
final fake = FakeRouteProvider();
|
||||||
|
ResolvedPlace? resolved;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: AddressAutocompleteField(
|
||||||
|
labelText: 'Meetup Location',
|
||||||
|
onPlaceResolved: (p) => resolved = p,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), 'Dahlonega');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.textContaining('Fake Result 1'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(resolved, isNotNull);
|
||||||
|
|
||||||
|
// Tap clear icon
|
||||||
|
await tester.tap(find.byIcon(Icons.clear));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(resolved, isNull);
|
||||||
|
expect(find.text(''), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('resolves place via provider if coordinates are missing in search result', (tester) async {
|
||||||
|
final fake = FakeRouteProvider(
|
||||||
|
customSearchResults: [
|
||||||
|
const PlaceSearchResult(
|
||||||
|
providerId: 'need_coords_id',
|
||||||
|
primaryText: 'Neels Gap Store',
|
||||||
|
secondaryText: 'Blairsville, GA',
|
||||||
|
// No coordinates
|
||||||
|
),
|
||||||
|
],
|
||||||
|
customResolvedPlace: const ResolvedPlace(
|
||||||
|
providerId: 'need_coords_id',
|
||||||
|
displayName: 'Neels Gap Store',
|
||||||
|
latitude: 34.8698,
|
||||||
|
longitude: -83.9182,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
ResolvedPlace? resolved;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: AddressAutocompleteField(
|
||||||
|
labelText: 'Destination',
|
||||||
|
onPlaceResolved: (p) => resolved = p,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), 'Neels');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Neels Gap Store'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(resolved, isNotNull);
|
||||||
|
expect(resolved!.latitude, 34.8698);
|
||||||
|
expect(resolved!.longitude, -83.9182);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,962 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:motorcycle_dash/features/group_rides/create_group_ride_screen.dart';
|
||||||
|
import 'package:motorcycle_dash/services/auth/auth_providers.dart';
|
||||||
|
import 'package:motorcycle_dash/services/auth/auth_user.dart';
|
||||||
|
import 'package:motorcycle_dash/services/group_rides/group_ride_models.dart';
|
||||||
|
import 'package:motorcycle_dash/services/group_rides/group_ride_service.dart';
|
||||||
|
import 'package:motorcycle_dash/services/group_rides/group_ride_providers.dart';
|
||||||
|
import 'package:motorcycle_dash/services/routes/route_models.dart';
|
||||||
|
import 'package:motorcycle_dash/services/routes/route_provider.dart';
|
||||||
|
import 'package:motorcycle_dash/services/routes/route_providers.dart';
|
||||||
|
import 'package:motorcycle_dash/services/social/rider_profile_models.dart';
|
||||||
|
import 'package:motorcycle_dash/services/social/social_providers.dart';
|
||||||
|
import 'package:motorcycle_dash/features/routes/widgets/address_autocomplete_field.dart';
|
||||||
|
import 'fake_route_provider.dart';
|
||||||
|
|
||||||
|
class _MockGroupRideService extends GroupRideService {
|
||||||
|
GroupRide? createdRide;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<GroupRide> createGroupRide({
|
||||||
|
required RiderProfile organizer,
|
||||||
|
required String title,
|
||||||
|
String description = '',
|
||||||
|
GroupRideVisibility visibility = GroupRideVisibility.friendsOnly,
|
||||||
|
GroupRideJoinPolicy joinPolicy = GroupRideJoinPolicy.inviteOnly,
|
||||||
|
required DateTime scheduledStartAt,
|
||||||
|
required DateTime departureAt,
|
||||||
|
int? participantLimit,
|
||||||
|
required GroupRideLocation meetingPoint,
|
||||||
|
GroupRideLocation? destination,
|
||||||
|
List<GroupRideLocation> stops = const [],
|
||||||
|
List<String> initialInviteUids = const [],
|
||||||
|
String? routeId,
|
||||||
|
String? routeTitle,
|
||||||
|
double? routeDistanceMeters,
|
||||||
|
int? routeStopCount,
|
||||||
|
}) async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
createdRide = GroupRide(
|
||||||
|
id: 'ride_new_test',
|
||||||
|
organizerUid: organizer.uid,
|
||||||
|
organizerHandle: organizer.handle,
|
||||||
|
organizerDisplayName: organizer.displayName,
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
visibility: visibility,
|
||||||
|
joinPolicy: joinPolicy,
|
||||||
|
scheduledStartAt: scheduledStartAt,
|
||||||
|
departureAt: departureAt,
|
||||||
|
participantLimit: participantLimit,
|
||||||
|
meetingPoint: meetingPoint,
|
||||||
|
destination: destination,
|
||||||
|
stops: stops,
|
||||||
|
routeId: routeId,
|
||||||
|
routeTitle: routeTitle,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
return createdRide!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('CreateGroupRideScreen Route-P2 Tests', () {
|
||||||
|
late RiderProfile profile;
|
||||||
|
late AuthUser user;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
profile = RiderProfile(
|
||||||
|
uid: 'alice',
|
||||||
|
handle: 'alice_apex',
|
||||||
|
displayName: 'Alice Apex',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
user = const AuthUser(
|
||||||
|
uid: 'alice',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('ambiguous AppBar Import icon is removed and USE EXISTING ROUTE action is visible',
|
||||||
|
(tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(400, 900));
|
||||||
|
|
||||||
|
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWithValue(user),
|
||||||
|
myRiderProfileProvider.overrideWith((ref) => Stream.value(RiderProfile(uid: 'alice', handle: 'alice_apex', displayName: 'Alice Apex', createdAt: DateTime.now(), updatedAt: DateTime.now()))),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(debugShowCheckedModeBanner: false,
|
||||||
|
home: CreateGroupRideScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify ambiguous AppBar icon is removed
|
||||||
|
final appBarFinder = find.byType(AppBar);
|
||||||
|
expect(
|
||||||
|
find.descendant(of: appBarFinder, matching: find.byIcon(Icons.alt_route)),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify explicit form button "USE EXISTING ROUTE" is visible
|
||||||
|
expect(find.text('USE EXISTING ROUTE'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('blocks publish when Meetup or Destination is unresolved text',
|
||||||
|
(tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
final mockService = _MockGroupRideService();
|
||||||
|
|
||||||
|
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWithValue(user),
|
||||||
|
myRiderProfileProvider.overrideWith((ref) => Stream.value(RiderProfile(uid: 'alice', handle: 'alice_apex', displayName: 'Alice Apex', createdAt: DateTime.now(), updatedAt: DateTime.now()))),
|
||||||
|
groupRideServiceProvider.overrideWithValue(mockService),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(debugShowCheckedModeBanner: false,
|
||||||
|
home: CreateGroupRideScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Enter valid title
|
||||||
|
final titleField = find.widgetWithText(TextFormField, 'Ride Title');
|
||||||
|
await tester.enterText(titleField, 'Weekend Cruise');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Enter unresolved text into Meetup field
|
||||||
|
final startSearchField = find.widgetWithText(
|
||||||
|
AddressAutocompleteField,
|
||||||
|
'Meeting Place Address / Location *',
|
||||||
|
);
|
||||||
|
final startInput = find.descendant(
|
||||||
|
of: startSearchField,
|
||||||
|
matching: find.byType(TextFormField),
|
||||||
|
);
|
||||||
|
await tester.enterText(startInput, 'Unresolved Meetup Spot');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Do not select resolved meetup from suggestions, just tap PUBLISH
|
||||||
|
await tester.tap(find.text('PUBLISH'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Validation error shown
|
||||||
|
expect(
|
||||||
|
find.textContaining('Please select a valid address from the suggestions'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(mockService.createdRide, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('calculates route and displays preview when Start and Destination resolved',
|
||||||
|
(tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
final fake = FakeRouteProvider(
|
||||||
|
customSearchResults: [
|
||||||
|
const PlaceSearchResult(
|
||||||
|
providerId: 'meetup_1',
|
||||||
|
primaryText: 'Dahlonega Chevron',
|
||||||
|
secondaryText: 'Dahlonega, GA',
|
||||||
|
latitude: 34.53,
|
||||||
|
longitude: -83.98,
|
||||||
|
),
|
||||||
|
const PlaceSearchResult(
|
||||||
|
providerId: 'dest_1',
|
||||||
|
primaryText: 'Neels Gap',
|
||||||
|
secondaryText: 'Blairsville, GA',
|
||||||
|
latitude: 34.88,
|
||||||
|
longitude: -83.92,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWithValue(user),
|
||||||
|
myRiderProfileProvider.overrideWith((ref) => Stream.value(RiderProfile(uid: 'alice', handle: 'alice_apex', displayName: 'Alice Apex', createdAt: DateTime.now(), updatedAt: DateTime.now()))),
|
||||||
|
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(debugShowCheckedModeBanner: false,
|
||||||
|
home: CreateGroupRideScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Fill Start address
|
||||||
|
final startSearchField = find.widgetWithText(
|
||||||
|
AddressAutocompleteField,
|
||||||
|
'Meeting Place Address / Location *',
|
||||||
|
);
|
||||||
|
final startInput = find.descendant(
|
||||||
|
of: startSearchField,
|
||||||
|
matching: find.byType(TextFormField),
|
||||||
|
);
|
||||||
|
await tester.enterText(startInput, 'Dahlonega');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Dahlonega Chevron').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Fill Destination address
|
||||||
|
final destSearchField = find.widgetWithText(
|
||||||
|
AddressAutocompleteField,
|
||||||
|
'Destination Address / Location *',
|
||||||
|
);
|
||||||
|
final destInput = find.descendant(
|
||||||
|
of: destSearchField,
|
||||||
|
matching: find.byType(TextFormField),
|
||||||
|
);
|
||||||
|
await tester.enterText(destInput, 'Neels');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Neels Gap').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify route preview section rendered with distance
|
||||||
|
expect(find.text('ROUTE PREVIEW'), findsOneWidget);
|
||||||
|
expect(find.textContaining('Road Est.'), findsOneWidget);
|
||||||
|
expect(find.text('Powered by Valhalla • Stadia Maps'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('allows adding, editing, and removing intermediate stops',
|
||||||
|
(tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
final fake = FakeRouteProvider(
|
||||||
|
customSearchResults: [
|
||||||
|
const PlaceSearchResult(
|
||||||
|
providerId: 'stop_p1',
|
||||||
|
primaryText: 'Turner Corner Gas',
|
||||||
|
secondaryText: 'Cleveland, GA',
|
||||||
|
latitude: 34.68,
|
||||||
|
longitude: -83.89,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWithValue(user),
|
||||||
|
myRiderProfileProvider.overrideWith((ref) => Stream.value(RiderProfile(uid: 'alice', handle: 'alice_apex', displayName: 'Alice Apex', createdAt: DateTime.now(), updatedAt: DateTime.now()))),
|
||||||
|
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(debugShowCheckedModeBanner: false,
|
||||||
|
home: CreateGroupRideScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Tap ADD STOP
|
||||||
|
await tester.tap(find.text('ADD STOP'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Add Intermediate Stop'), findsOneWidget);
|
||||||
|
|
||||||
|
// Search stop address
|
||||||
|
final dialogSearch = find.descendant(
|
||||||
|
of: find.byType(AlertDialog),
|
||||||
|
matching: find.byType(AddressAutocompleteField),
|
||||||
|
);
|
||||||
|
final dialogSearchInput = find.descendant(
|
||||||
|
of: dialogSearch,
|
||||||
|
matching: find.byType(TextFormField),
|
||||||
|
);
|
||||||
|
await tester.enterText(dialogSearchInput, 'Turner');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Turner Corner Gas'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Add Stop
|
||||||
|
await tester.tap(find.text('ADD STOP').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify stop added in intermediate list
|
||||||
|
expect(find.text('Turner Corner Gas'), findsOneWidget);
|
||||||
|
expect(find.textContaining('Intermediate Stops (2/25 max)'), findsOneWidget);
|
||||||
|
|
||||||
|
// Delete stop
|
||||||
|
await tester.tap(find.byIcon(Icons.delete_outline));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Turner Corner Gas'), findsNothing);
|
||||||
|
expect(find.textContaining('Intermediate Stops (1/25 max)'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('USE EXISTING ROUTE loads saved route stops and recalculates route',
|
||||||
|
(tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
final savedRoute = MotorSignalRoute(
|
||||||
|
id: 'route_saved_1',
|
||||||
|
title: 'Georgia Mountain Loop',
|
||||||
|
description: 'Twisty scenic mountain ride',
|
||||||
|
ownerUid: 'alice',
|
||||||
|
ownerHandle: 'alice_apex',
|
||||||
|
visibility: RouteVisibility.public,
|
||||||
|
straightLineDistanceMeters: 55000,
|
||||||
|
stops: const [
|
||||||
|
RouteStop(
|
||||||
|
id: 's_start',
|
||||||
|
displayName: 'Suches Community Center',
|
||||||
|
latitude: 34.689,
|
||||||
|
longitude: -84.025,
|
||||||
|
order: 0,
|
||||||
|
stopType: RouteStopType.origin,
|
||||||
|
),
|
||||||
|
RouteStop(
|
||||||
|
id: 's_mid',
|
||||||
|
displayName: 'Vogel State Park',
|
||||||
|
latitude: 34.764,
|
||||||
|
longitude: -83.924,
|
||||||
|
order: 1,
|
||||||
|
stopType: RouteStopType.waypoint,
|
||||||
|
),
|
||||||
|
RouteStop(
|
||||||
|
id: 's_end',
|
||||||
|
displayName: 'Two Wheels of Suches',
|
||||||
|
latitude: 34.693,
|
||||||
|
longitude: -84.023,
|
||||||
|
order: 2,
|
||||||
|
stopType: RouteStopType.destination,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final fake = FakeRouteProvider();
|
||||||
|
|
||||||
|
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWithValue(user),
|
||||||
|
myRiderProfileProvider.overrideWith((ref) => Stream.value(RiderProfile(uid: 'alice', handle: 'alice_apex', displayName: 'Alice Apex', createdAt: DateTime.now(), updatedAt: DateTime.now()))),
|
||||||
|
userRoutesProvider('alice').overrideWith((ref) => Stream.value([savedRoute])),
|
||||||
|
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(debugShowCheckedModeBanner: false,
|
||||||
|
home: CreateGroupRideScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Tap USE EXISTING ROUTE
|
||||||
|
await tester.tap(find.text('USE EXISTING ROUTE'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify bottom sheet appears
|
||||||
|
expect(find.text('Georgia Mountain Loop'), findsOneWidget);
|
||||||
|
|
||||||
|
// Select route
|
||||||
|
await tester.tap(find.text('Georgia Mountain Loop'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify fields populated
|
||||||
|
expect(find.text('Georgia Mountain Loop'), findsWidgets);
|
||||||
|
expect(find.text('Suches Community Center'), findsWidgets);
|
||||||
|
expect(find.text('Vogel State Park'), findsOneWidget);
|
||||||
|
expect(find.text('Two Wheels of Suches'), findsWidgets);
|
||||||
|
expect(find.text('Route: Georgia Mountain Loop'), findsOneWidget);
|
||||||
|
|
||||||
|
// Verify route preview recalculated
|
||||||
|
expect(find.text('ROUTE PREVIEW'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('submits and publishes GroupRide with valid resolved locations and stops',
|
||||||
|
(tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
final mockService = _MockGroupRideService();
|
||||||
|
final fake = FakeRouteProvider(
|
||||||
|
customSearchResults: [
|
||||||
|
const PlaceSearchResult(
|
||||||
|
providerId: 'meetup_1',
|
||||||
|
primaryText: 'Dahlonega Chevron',
|
||||||
|
secondaryText: 'Dahlonega, GA',
|
||||||
|
latitude: 34.53,
|
||||||
|
longitude: -83.98,
|
||||||
|
),
|
||||||
|
const PlaceSearchResult(
|
||||||
|
providerId: 'dest_1',
|
||||||
|
primaryText: 'Neels Gap',
|
||||||
|
secondaryText: 'Blairsville, GA',
|
||||||
|
latitude: 34.88,
|
||||||
|
longitude: -83.92,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWithValue(user),
|
||||||
|
myRiderProfileProvider.overrideWith((ref) => Stream.value(RiderProfile(uid: 'alice', handle: 'alice_apex', displayName: 'Alice Apex', createdAt: DateTime.now(), updatedAt: DateTime.now()))),
|
||||||
|
activeRouteCalculationProvider.overrideWithValue(fake),
|
||||||
|
groupRideServiceProvider.overrideWithValue(mockService),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(debugShowCheckedModeBanner: false,
|
||||||
|
home: CreateGroupRideScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Fill title
|
||||||
|
final titleField = find.widgetWithText(TextFormField, 'Ride Title');
|
||||||
|
await tester.enterText(titleField, 'Blood Mountain Fast Run');
|
||||||
|
|
||||||
|
// Meetup
|
||||||
|
final startSearchField = find.widgetWithText(
|
||||||
|
AddressAutocompleteField,
|
||||||
|
'Meeting Place Address / Location *',
|
||||||
|
);
|
||||||
|
final startInput = find.descendant(
|
||||||
|
of: startSearchField,
|
||||||
|
matching: find.byType(TextFormField),
|
||||||
|
);
|
||||||
|
await tester.enterText(startInput, 'Dahlonega');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Dahlonega Chevron').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Destination
|
||||||
|
final destSearchField = find.widgetWithText(
|
||||||
|
AddressAutocompleteField,
|
||||||
|
'Destination Address / Location *',
|
||||||
|
);
|
||||||
|
final destInput = find.descendant(
|
||||||
|
of: destSearchField,
|
||||||
|
matching: find.byType(TextFormField),
|
||||||
|
);
|
||||||
|
await tester.enterText(destInput, 'Neels');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Neels Gap').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Tap PUBLISH
|
||||||
|
await tester.tap(find.text('PUBLISH'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify ride created with correct coordinates
|
||||||
|
expect(mockService.createdRide, isNotNull);
|
||||||
|
expect(mockService.createdRide!.title, 'Blood Mountain Fast Run');
|
||||||
|
expect(mockService.createdRide!.meetingPoint.displayName, 'Dahlonega Chevron');
|
||||||
|
expect(mockService.createdRide!.meetingPoint.latitude, 34.53);
|
||||||
|
expect(mockService.createdRide!.destination!.displayName, 'Neels Gap');
|
||||||
|
expect(mockService.createdRide!.destination!.latitude, 34.88);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
group('ROUTE-P2 Final Pre-Commit Forensic Gate', () {
|
||||||
|
testWidgets('VERIFY EXACT ROUTE ORDER: Start + Destination', (tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
final mockService = _MockGroupRideService();
|
||||||
|
final fakeRouteProvider = FakeRouteProvider(
|
||||||
|
customSearchResults: [
|
||||||
|
const PlaceSearchResult(providerId: 'meetup_1', primaryText: 'Dahlonega Chevron', secondaryText: 'Dahlonega, GA', latitude: 34.53, longitude: -83.98),
|
||||||
|
const PlaceSearchResult(providerId: 'stop_2', primaryText: 'Stop 2 Place', secondaryText: 'Stop 2 City', latitude: 34.70, longitude: -83.90),
|
||||||
|
const PlaceSearchResult(providerId: 'dest_1', primaryText: 'Neels Gap', secondaryText: 'Blood Mountain', latitude: 34.88, longitude: -83.99),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
groupRideServiceProvider.overrideWithValue(mockService),
|
||||||
|
myRiderProfileProvider.overrideWith((_) => Stream.value(RiderProfile(
|
||||||
|
uid: 'uid_123',
|
||||||
|
handle: 'testrider', displayName: 'Test Rider', createdAt: DateTime.now(), updatedAt: DateTime.now(),
|
||||||
|
))),
|
||||||
|
activeRouteCalculationProvider.overrideWithValue(fakeRouteProvider),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
home: Material(
|
||||||
|
child: CreateGroupRideScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Meetup
|
||||||
|
final searchField = find.widgetWithText(AddressAutocompleteField, 'Meeting Place Address / Location *');
|
||||||
|
final input = find.descendant(of: searchField, matching: find.byType(TextFormField));
|
||||||
|
await tester.enterText(input, 'Start');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Dahlonega Chevron').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Destination (already visible, hasDestination=true by default)
|
||||||
|
final destSearchField = find.widgetWithText(AddressAutocompleteField, 'Destination Address / Location *');
|
||||||
|
final destInput = find.descendant(of: destSearchField, matching: find.byType(TextFormField));
|
||||||
|
await tester.enterText(destInput, 'Neels');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Neels Gap').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(fakeRouteProvider.lastCapturedStops, isNotNull);
|
||||||
|
final stops = fakeRouteProvider.lastCapturedStops!;
|
||||||
|
|
||||||
|
expect(stops.length, 2);
|
||||||
|
expect(stops[0].stopType, RouteStopType.origin);
|
||||||
|
expect(stops[0].displayName, 'Dahlonega Chevron');
|
||||||
|
|
||||||
|
expect(stops[1].stopType, RouteStopType.destination);
|
||||||
|
expect(stops[1].displayName, 'Neels Gap');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('VERIFY EXACT ROUTE ORDER: Start + Stop1 + Destination', (tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
final mockService = _MockGroupRideService();
|
||||||
|
final fakeRouteProvider = FakeRouteProvider(
|
||||||
|
customSearchResults: [
|
||||||
|
const PlaceSearchResult(providerId: 'meetup_1', primaryText: 'Dahlonega Chevron', secondaryText: 'Dahlonega, GA', latitude: 34.53, longitude: -83.98),
|
||||||
|
const PlaceSearchResult(providerId: 'stop_2', primaryText: 'Stop 2 Place', secondaryText: 'Stop 2 City', latitude: 34.70, longitude: -83.90),
|
||||||
|
const PlaceSearchResult(providerId: 'dest_1', primaryText: 'Neels Gap', secondaryText: 'Blood Mountain', latitude: 34.88, longitude: -83.99),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
groupRideServiceProvider.overrideWithValue(mockService),
|
||||||
|
myRiderProfileProvider.overrideWith((_) => Stream.value(RiderProfile(
|
||||||
|
uid: 'uid_123',
|
||||||
|
handle: 'testrider', displayName: 'Test Rider', createdAt: DateTime.now(), updatedAt: DateTime.now(),
|
||||||
|
))),
|
||||||
|
activeRouteCalculationProvider.overrideWithValue(fakeRouteProvider),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
home: Material(
|
||||||
|
child: CreateGroupRideScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Meetup
|
||||||
|
final searchField = find.widgetWithText(AddressAutocompleteField, 'Meeting Place Address / Location *');
|
||||||
|
final input = find.descendant(of: searchField, matching: find.byType(TextFormField));
|
||||||
|
await tester.enterText(input, 'Start');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Dahlonega Chevron').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Stop 1
|
||||||
|
await tester.dragUntilVisible(find.text('ADD STOP').first, find.byType(Scrollable).first, const Offset(0, -100)); await tester.pumpAndSettle(); await tester.tap(find.text('ADD STOP').first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final stopSearchField = find.widgetWithText(AddressAutocompleteField, 'Stop Address / Location *');
|
||||||
|
final stopInput = find.descendant(of: stopSearchField, matching: find.byType(TextFormField));
|
||||||
|
await tester.enterText(stopInput, 'Stop1');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Dahlonega Chevron').last); // Fake returns this for everything
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('ADD STOP').last); // Add button in dialog
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Destination
|
||||||
|
final destSearchField = find.widgetWithText(AddressAutocompleteField, 'Destination Address / Location *');
|
||||||
|
final destInput = find.descendant(of: destSearchField, matching: find.byType(TextFormField));
|
||||||
|
await tester.enterText(destInput, 'Neels');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Neels Gap').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(fakeRouteProvider.lastCapturedStops, isNotNull);
|
||||||
|
final stops = fakeRouteProvider.lastCapturedStops!;
|
||||||
|
|
||||||
|
expect(stops.length, 3);
|
||||||
|
expect(stops[0].stopType, RouteStopType.origin);
|
||||||
|
expect(stops[1].stopType, RouteStopType.waypoint);
|
||||||
|
expect(stops[2].stopType, RouteStopType.destination);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('VERIFY EXACT ROUTE ORDER: Start + Stop1 + Stop2 + Destination', (tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
final mockService = _MockGroupRideService();
|
||||||
|
final fakeRouteProvider = FakeRouteProvider(
|
||||||
|
customSearchResults: [
|
||||||
|
const PlaceSearchResult(providerId: 'meetup_1', primaryText: 'Dahlonega Chevron', secondaryText: 'Dahlonega, GA', latitude: 34.53, longitude: -83.98),
|
||||||
|
const PlaceSearchResult(providerId: 'stop_2', primaryText: 'Stop 2 Place', secondaryText: 'Stop 2 City', latitude: 34.70, longitude: -83.90),
|
||||||
|
const PlaceSearchResult(providerId: 'dest_1', primaryText: 'Neels Gap', secondaryText: 'Blood Mountain', latitude: 34.88, longitude: -83.99),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
groupRideServiceProvider.overrideWithValue(mockService),
|
||||||
|
myRiderProfileProvider.overrideWith((_) => Stream.value(RiderProfile(
|
||||||
|
uid: 'uid_123',
|
||||||
|
handle: 'testrider', displayName: 'Test Rider', createdAt: DateTime.now(), updatedAt: DateTime.now(),
|
||||||
|
))),
|
||||||
|
activeRouteCalculationProvider.overrideWithValue(fakeRouteProvider),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
home: Material(
|
||||||
|
child: CreateGroupRideScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Meetup
|
||||||
|
final searchField = find.widgetWithText(AddressAutocompleteField, 'Meeting Place Address / Location *');
|
||||||
|
final input = find.descendant(of: searchField, matching: find.byType(TextFormField));
|
||||||
|
await tester.enterText(input, 'Start');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Dahlonega Chevron').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Stop 1
|
||||||
|
await tester.dragUntilVisible(find.text('ADD STOP').first, find.byType(Scrollable).first, const Offset(0, -100)); await tester.pumpAndSettle(); await tester.tap(find.text('ADD STOP').first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final stopSearchField1 = find.widgetWithText(AddressAutocompleteField, 'Stop Address / Location *');
|
||||||
|
final stopInput1 = find.descendant(of: stopSearchField1, matching: find.byType(TextFormField));
|
||||||
|
await tester.enterText(stopInput1, 'Stop1');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Dahlonega Chevron').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('ADD STOP').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Stop 2
|
||||||
|
await tester.dragUntilVisible(find.text('ADD STOP').first, find.byType(Scrollable).first, const Offset(0, -100)); await tester.pumpAndSettle(); await tester.tap(find.text('ADD STOP').first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final stopSearchFields = find.widgetWithText(AddressAutocompleteField, 'Stop Address / Location *');
|
||||||
|
final stopInput2 = find.descendant(of: stopSearchFields.last, matching: find.byType(TextFormField));
|
||||||
|
await tester.enterText(stopInput2, 'Stop2');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Stop 2 Place').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('ADD STOP').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Destination
|
||||||
|
final destSearchField = find.widgetWithText(AddressAutocompleteField, 'Destination Address / Location *');
|
||||||
|
final destInput = find.descendant(of: destSearchField, matching: find.byType(TextFormField));
|
||||||
|
await tester.enterText(destInput, 'Neels');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Neels Gap').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(fakeRouteProvider.lastCapturedStops, isNotNull);
|
||||||
|
final stops = fakeRouteProvider.lastCapturedStops!;
|
||||||
|
|
||||||
|
expect(stops.length, 4);
|
||||||
|
expect(stops[0].stopType, RouteStopType.origin);
|
||||||
|
expect(stops[1].stopType, RouteStopType.waypoint);
|
||||||
|
expect(stops[2].stopType, RouteStopType.waypoint);
|
||||||
|
expect(stops[3].stopType, RouteStopType.destination);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ROUTE-P2 REAL RESPONSIVE TESTS', () {
|
||||||
|
testWidgets('Pixel-like portrait (412x915)', (tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(412, 915));
|
||||||
|
final fakeRouteProvider = FakeRouteProvider(
|
||||||
|
customSearchResults: [
|
||||||
|
const PlaceSearchResult(providerId: 'meetup_1', primaryText: 'Result 1', secondaryText: 'City 1', latitude: 34.1, longitude: -83.1),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final profile = RiderProfile(uid: 'alice', handle: 'alice_apex', displayName: 'Alice Apex', createdAt: now, updatedAt: now);
|
||||||
|
final user = const AuthUser(uid: 'alice', email: 'alice@example.com');
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWithValue(user),
|
||||||
|
myRiderProfileProvider.overrideWith((ref) => Stream.value(profile)),
|
||||||
|
activeRouteCalculationProvider.overrideWithValue(fakeRouteProvider),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
addTearDown(() {
|
||||||
|
tester.binding.setSurfaceSize(null);
|
||||||
|
container.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
|
child: MaterialApp(
|
||||||
|
home: const CreateGroupRideScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final searchFields = find.widgetWithText(AddressAutocompleteField, 'Meeting Place Address / Location *');
|
||||||
|
final meetupInput = find.descendant(of: searchFields.first, matching: find.byType(TextFormField));
|
||||||
|
await tester.enterText(meetupInput, 'Start');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
for (int i = 0; i < 20; i++) {
|
||||||
|
if (tester.any(find.text('ADD STOP').first)) {
|
||||||
|
final dy = tester.getCenter(find.text('ADD STOP').first).dy;
|
||||||
|
if (dy > 100 && dy < 300) break;
|
||||||
|
}
|
||||||
|
await tester.drag(find.byType(Scrollable).first, const Offset(0, -100));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
await tester.tap(find.text('ADD STOP').first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final stopFields = find.widgetWithText(AddressAutocompleteField, 'Stop Address / Location *');
|
||||||
|
final stopInput = find.descendant(of: stopFields.last, matching: find.byType(TextFormField));
|
||||||
|
await tester.enterText(stopInput, 'Stop1');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('CANCEL'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
for (int i = 0; i < 20; i++) {
|
||||||
|
final destSearchField = find.widgetWithText(AddressAutocompleteField, 'Destination Address / Location *');
|
||||||
|
if (tester.any(destSearchField)) {
|
||||||
|
final dy = tester.getCenter(destSearchField).dy;
|
||||||
|
if (dy > 100 && dy < 300) break;
|
||||||
|
}
|
||||||
|
await tester.drag(find.byType(Scrollable).first, const Offset(0, -100));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
final destSearchField = find.widgetWithText(AddressAutocompleteField, 'Destination Address / Location *');
|
||||||
|
final destInput = find.descendant(of: destSearchField, matching: find.byType(TextFormField));
|
||||||
|
await tester.enterText(destInput, 'Dest');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Short landscape (915x412)', (tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(915, 412));
|
||||||
|
final fakeRouteProvider = FakeRouteProvider(
|
||||||
|
customSearchResults: [
|
||||||
|
const PlaceSearchResult(providerId: 'meetup_1', primaryText: 'Result 1', secondaryText: 'City 1', latitude: 34.1, longitude: -83.1),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final profile = RiderProfile(uid: 'alice', handle: 'alice_apex', displayName: 'Alice Apex', createdAt: now, updatedAt: now);
|
||||||
|
final user = const AuthUser(uid: 'alice', email: 'alice@example.com');
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWithValue(user),
|
||||||
|
myRiderProfileProvider.overrideWith((ref) => Stream.value(profile)),
|
||||||
|
activeRouteCalculationProvider.overrideWithValue(fakeRouteProvider),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
addTearDown(() {
|
||||||
|
tester.binding.setSurfaceSize(null);
|
||||||
|
container.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
|
child: MaterialApp(
|
||||||
|
home: const CreateGroupRideScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final searchFields = find.widgetWithText(AddressAutocompleteField, 'Meeting Place Address / Location *');
|
||||||
|
final meetupInput = find.descendant(of: searchFields.first, matching: find.byType(TextFormField));
|
||||||
|
await tester.enterText(meetupInput, 'Start');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
for (int i = 0; i < 20; i++) {
|
||||||
|
if (tester.any(find.text('ADD STOP').first)) {
|
||||||
|
final dy = tester.getCenter(find.text('ADD STOP').first).dy;
|
||||||
|
if (dy > 100 && dy < 300) break;
|
||||||
|
}
|
||||||
|
await tester.drag(find.byType(Scrollable).first, const Offset(0, -100));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
await tester.tap(find.text('ADD STOP').first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final stopFields = find.widgetWithText(AddressAutocompleteField, 'Stop Address / Location *');
|
||||||
|
final stopInput = find.descendant(of: stopFields.last, matching: find.byType(TextFormField));
|
||||||
|
await tester.enterText(stopInput, 'Stop1');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('CANCEL'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
for (int i = 0; i < 20; i++) {
|
||||||
|
final destSearchField = find.widgetWithText(AddressAutocompleteField, 'Destination Address / Location *');
|
||||||
|
if (tester.any(destSearchField)) {
|
||||||
|
final dy = tester.getCenter(destSearchField).dy;
|
||||||
|
if (dy > 100 && dy < 300) break;
|
||||||
|
}
|
||||||
|
await tester.drag(find.byType(Scrollable).first, const Offset(0, -100));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
final destSearchField = find.widgetWithText(AddressAutocompleteField, 'Destination Address / Location *');
|
||||||
|
final destInput = find.descendant(of: destSearchField, matching: find.byType(TextFormField));
|
||||||
|
await tester.enterText(destInput, 'Dest');
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
testWidgets('ROUTE RACE - In-flight calculation is discarded if sequence advances', (tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
Completer<void>? calc1Completer;
|
||||||
|
|
||||||
|
final fakeRouteProvider = FakeRouteProvider(
|
||||||
|
customSearchResults: [
|
||||||
|
const PlaceSearchResult(providerId: 'meetup_1', primaryText: 'Start', secondaryText: 'City 1', latitude: 34.1, longitude: -83.1),
|
||||||
|
const PlaceSearchResult(providerId: 'dest_1', primaryText: 'Dest1', secondaryText: 'City 2', latitude: 34.2, longitude: -83.2),
|
||||||
|
],
|
||||||
|
onCalculate: (count) async {
|
||||||
|
print('ON CALCULATE CALLED WITH COUNT: '); if (count == 0) {
|
||||||
|
calc1Completer = Completer<void>();
|
||||||
|
await calc1Completer!.future;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
activeRouteCalculationProvider.overrideWithValue(fakeRouteProvider),
|
||||||
|
currentUserProvider.overrideWithValue(const AuthUser(uid: 'alice', email: 'alice@example.com')),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
|
child: const MaterialApp(home: CreateGroupRideScreen()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Step 1: Add Meetup
|
||||||
|
final meetupField = find.widgetWithText(AddressAutocompleteField, 'Meeting Place Address / Location *');
|
||||||
|
await tester.enterText(find.descendant(of: meetupField, matching: find.byType(TextFormField)), 'Start');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Start').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Step 2: Add Destination
|
||||||
|
final destField = find.widgetWithText(AddressAutocompleteField, 'Destination Address / Location *');
|
||||||
|
await tester.enterText(find.descendant(of: destField, matching: find.byType(TextFormField)), 'Dest1');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Tap Dest1 -> triggers Calculation 0 (which stalls because count == 0)
|
||||||
|
await tester.tap(find.text('Dest1').last);
|
||||||
|
await tester.pump(); // Start calc
|
||||||
|
|
||||||
|
// Step 3: While Calc 0 is stalled, clear the destination!
|
||||||
|
// This triggers sequence increment and an early return.
|
||||||
|
await tester.enterText(find.descendant(of: destField, matching: find.byType(TextFormField)), '');
|
||||||
|
|
||||||
|
// Tap the 'clear' icon if there is one, or just unfocus
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Step 4: Now finish Calc 0!
|
||||||
|
calc1Completer?.complete();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verification: The stale Calc 0 should NOT be shown!
|
||||||
|
// The UI should show no distance label.
|
||||||
|
expect(find.textContaining('miles'), findsNothing);
|
||||||
|
expect(find.textContaining('km'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,24 @@
|
||||||
import 'package:motorcycle_dash/services/map/map_provider_interface.dart';
|
import 'package:motorcycle_dash/services/map/map_provider_interface.dart';
|
||||||
import 'package:motorcycle_dash/services/routes/route_models.dart';
|
import 'package:motorcycle_dash/services/routes/route_models.dart';
|
||||||
import 'package:motorcycle_dash/services/routes/route_provider.dart';
|
import 'package:motorcycle_dash/services/routes/route_provider.dart';
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
class FakeRouteProvider implements RouteProvider {
|
class FakeRouteProvider implements RouteProvider {
|
||||||
final bool shouldFail;
|
final bool shouldFail;
|
||||||
final bool shouldTimeout;
|
final bool shouldTimeout;
|
||||||
|
final List<PlaceSearchResult>? customSearchResults;
|
||||||
|
final ResolvedPlace? customResolvedPlace;
|
||||||
|
final Future<void> Function(int count)? onCalculate;
|
||||||
|
int _calcCount = 0;
|
||||||
|
|
||||||
|
List<RouteStop>? lastCapturedStops;
|
||||||
|
|
||||||
FakeRouteProvider({
|
FakeRouteProvider({
|
||||||
this.shouldFail = false,
|
this.shouldFail = false,
|
||||||
this.shouldTimeout = false,
|
this.shouldTimeout = false,
|
||||||
|
this.customSearchResults,
|
||||||
|
this.customResolvedPlace,
|
||||||
|
this.onCalculate,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -21,6 +31,12 @@ class FakeRouteProvider implements RouteProvider {
|
||||||
bool avoidTolls = false,
|
bool avoidTolls = false,
|
||||||
bool avoidFerries = false,
|
bool avoidFerries = false,
|
||||||
}) async {
|
}) async {
|
||||||
|
lastCapturedStops = stops;
|
||||||
|
|
||||||
|
if (onCalculate != null) {
|
||||||
|
await onCalculate!(_calcCount++);
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldTimeout) {
|
if (shouldTimeout) {
|
||||||
await Future.delayed(const Duration(seconds: 2));
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
throw RouteProviderException('providerTimeout', 'Fake timeout');
|
throw RouteProviderException('providerTimeout', 'Fake timeout');
|
||||||
|
|
@ -54,24 +70,17 @@ class FakeRouteProvider implements RouteProvider {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ResolvedPlace> resolvePlace(String placeId) async {
|
|
||||||
if (shouldFail) throw RouteProviderException('placeNotFound', 'Fake not found');
|
|
||||||
return ResolvedPlace(
|
|
||||||
providerId: placeId,
|
|
||||||
displayName: 'Fake Place',
|
|
||||||
latitude: 0.0,
|
|
||||||
longitude: 0.0,
|
|
||||||
attribution: 'Fake Attribution',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<PlaceSearchResult>> searchPlaces(
|
Future<List<PlaceSearchResult>> searchPlaces(
|
||||||
String query, {
|
String query, {
|
||||||
AppLatLng? nearLocation,
|
AppLatLng? nearLocation,
|
||||||
}) async {
|
}) async {
|
||||||
if (shouldFail) throw RouteProviderException('placeSearchUnavailable', 'Fake search error');
|
if (shouldFail) {
|
||||||
|
throw RouteProviderException('placeSearchUnavailable', 'Fake search error');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customSearchResults != null) return customSearchResults!;
|
||||||
|
|
||||||
if (query.length < 2) return [];
|
if (query.length < 2) return [];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
@ -83,4 +92,19 @@ class FakeRouteProvider implements RouteProvider {
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ResolvedPlace> resolvePlace(String providerPlaceId) async {
|
||||||
|
if (shouldFail) {
|
||||||
|
throw RouteProviderException('placeNotFound', 'Fake not found');
|
||||||
|
}
|
||||||
|
if (customResolvedPlace != null) return customResolvedPlace!;
|
||||||
|
return ResolvedPlace(
|
||||||
|
providerId: providerPlaceId,
|
||||||
|
displayName: 'Fake Place',
|
||||||
|
latitude: 0.0,
|
||||||
|
longitude: 0.0,
|
||||||
|
attribution: 'Fake Attribution',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,299 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:motorcycle_dash/features/group_rides/live_group_ride_screen.dart';
|
||||||
|
import 'package:motorcycle_dash/services/auth/auth_user.dart';
|
||||||
|
import 'package:motorcycle_dash/services/auth/auth_providers.dart';
|
||||||
|
import 'package:motorcycle_dash/services/group_rides/group_ride_models.dart';
|
||||||
|
import 'package:motorcycle_dash/services/group_rides/group_ride_providers.dart';
|
||||||
|
import 'package:motorcycle_dash/services/group_rides/group_ride_service.dart';
|
||||||
|
|
||||||
|
class _FakeGroupRideService extends GroupRideService {
|
||||||
|
String? lastCallerUid;
|
||||||
|
String? lastGroupRideId;
|
||||||
|
GroupRideStatus? lastNewStatus;
|
||||||
|
int updateRideStatusCallCount = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateRideStatus({
|
||||||
|
required String callerUid,
|
||||||
|
required String groupRideId,
|
||||||
|
required GroupRideStatus newStatus,
|
||||||
|
}) async {
|
||||||
|
lastCallerUid = callerUid;
|
||||||
|
lastGroupRideId = groupRideId;
|
||||||
|
lastNewStatus = newStatus;
|
||||||
|
updateRideStatusCallCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('LiveGroupRideScreen - Organizer End Ride Action Tests', () {
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
final underwayRide = GroupRide(
|
||||||
|
id: 'ride_test_123',
|
||||||
|
organizerUid: 'organizer_alice',
|
||||||
|
organizerHandle: 'alice_apex',
|
||||||
|
organizerDisplayName: 'Alice Apex',
|
||||||
|
title: 'Sunday Morning Twisties',
|
||||||
|
status: GroupRideStatus.underway,
|
||||||
|
scheduledStartAt: now,
|
||||||
|
departureAt: now,
|
||||||
|
participantCount: 2,
|
||||||
|
meetingPoint: const GroupRideLocation(
|
||||||
|
latitude: 34.0,
|
||||||
|
longitude: -84.0,
|
||||||
|
displayName: 'Start Point',
|
||||||
|
),
|
||||||
|
destination: const GroupRideLocation(
|
||||||
|
latitude: 34.5,
|
||||||
|
longitude: -84.5,
|
||||||
|
displayName: 'End Point',
|
||||||
|
),
|
||||||
|
stops: const [],
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
final completedRide = underwayRide.copyWith(
|
||||||
|
status: GroupRideStatus.completed,
|
||||||
|
);
|
||||||
|
|
||||||
|
final participants = [
|
||||||
|
GroupRideParticipant(
|
||||||
|
userId: 'organizer_alice',
|
||||||
|
role: GroupRideRole.lead,
|
||||||
|
status: ParticipantStatus.accepted,
|
||||||
|
riderHandle: 'alice_apex',
|
||||||
|
riderDisplayName: 'Alice Apex',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
),
|
||||||
|
GroupRideParticipant(
|
||||||
|
userId: 'rider_bob',
|
||||||
|
role: GroupRideRole.participant,
|
||||||
|
status: ParticipantStatus.accepted,
|
||||||
|
riderHandle: 'bob_rider',
|
||||||
|
riderDisplayName: 'Bob Rider',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
testWidgets('organizer sees "Ride Options" popup menu with "End Ride" when underway',
|
||||||
|
(tester) async {
|
||||||
|
final fakeService = _FakeGroupRideService();
|
||||||
|
const user = AuthUser(
|
||||||
|
uid: 'organizer_alice',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
isAnonymous: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWithValue(user),
|
||||||
|
groupRideServiceProvider.overrideWithValue(fakeService),
|
||||||
|
groupRideStreamProvider('ride_test_123')
|
||||||
|
.overrideWith((ref) => Stream.value(underwayRide)),
|
||||||
|
groupRideParticipantsProvider('ride_test_123')
|
||||||
|
.overrideWith((ref) => Stream.value(participants)),
|
||||||
|
groupRideLivePresenceProvider('ride_test_123')
|
||||||
|
.overrideWith((ref) => Stream.value([])),
|
||||||
|
groupRideLiveSharingAllowedProvider.overrideWith((ref) => true),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: LiveGroupRideScreen(rideId: 'ride_test_123'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Ride Options menu button should be visible
|
||||||
|
final rideOptionsFinder = find.byTooltip('Ride Options');
|
||||||
|
expect(rideOptionsFinder, findsOneWidget);
|
||||||
|
|
||||||
|
// Open the popup menu
|
||||||
|
await tester.tap(rideOptionsFinder);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// "End Ride" menu item should be visible
|
||||||
|
expect(find.text('End Ride'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('non-organizer participant does NOT see "Ride Options" popup menu',
|
||||||
|
(tester) async {
|
||||||
|
final fakeService = _FakeGroupRideService();
|
||||||
|
const nonOrganizerUser = AuthUser(
|
||||||
|
uid: 'rider_bob',
|
||||||
|
email: 'bob@example.com',
|
||||||
|
isAnonymous: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWithValue(nonOrganizerUser),
|
||||||
|
groupRideServiceProvider.overrideWithValue(fakeService),
|
||||||
|
groupRideStreamProvider('ride_test_123')
|
||||||
|
.overrideWith((ref) => Stream.value(underwayRide)),
|
||||||
|
groupRideParticipantsProvider('ride_test_123')
|
||||||
|
.overrideWith((ref) => Stream.value(participants)),
|
||||||
|
groupRideLivePresenceProvider('ride_test_123')
|
||||||
|
.overrideWith((ref) => Stream.value([])),
|
||||||
|
groupRideLiveSharingAllowedProvider.overrideWith((ref) => true),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: LiveGroupRideScreen(rideId: 'ride_test_123'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Non-organizer should NOT see Ride Options
|
||||||
|
expect(find.byTooltip('Ride Options'), findsNothing);
|
||||||
|
expect(find.text('End Ride'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('organizer does NOT see "Ride Options" popup menu when ride is completed',
|
||||||
|
(tester) async {
|
||||||
|
final fakeService = _FakeGroupRideService();
|
||||||
|
const user = AuthUser(
|
||||||
|
uid: 'organizer_alice',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
isAnonymous: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWithValue(user),
|
||||||
|
groupRideServiceProvider.overrideWithValue(fakeService),
|
||||||
|
groupRideStreamProvider('ride_test_123')
|
||||||
|
.overrideWith((ref) => Stream.value(completedRide)),
|
||||||
|
groupRideParticipantsProvider('ride_test_123')
|
||||||
|
.overrideWith((ref) => Stream.value(participants)),
|
||||||
|
groupRideLivePresenceProvider('ride_test_123')
|
||||||
|
.overrideWith((ref) => Stream.value([])),
|
||||||
|
groupRideLiveSharingAllowedProvider.overrideWith((ref) => true),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: LiveGroupRideScreen(rideId: 'ride_test_123'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byTooltip('Ride Options'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tapping End Ride shows confirmation dialog and CANCEL dismisses without state change',
|
||||||
|
(tester) async {
|
||||||
|
final fakeService = _FakeGroupRideService();
|
||||||
|
const user = AuthUser(
|
||||||
|
uid: 'organizer_alice',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
isAnonymous: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWithValue(user),
|
||||||
|
groupRideServiceProvider.overrideWithValue(fakeService),
|
||||||
|
groupRideStreamProvider('ride_test_123')
|
||||||
|
.overrideWith((ref) => Stream.value(underwayRide)),
|
||||||
|
groupRideParticipantsProvider('ride_test_123')
|
||||||
|
.overrideWith((ref) => Stream.value(participants)),
|
||||||
|
groupRideLivePresenceProvider('ride_test_123')
|
||||||
|
.overrideWith((ref) => Stream.value([])),
|
||||||
|
groupRideLiveSharingAllowedProvider.overrideWith((ref) => true),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: LiveGroupRideScreen(rideId: 'ride_test_123'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Tap Ride Options
|
||||||
|
await tester.tap(find.byTooltip('Ride Options'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Tap End Ride
|
||||||
|
await tester.tap(find.text('End Ride'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Dialog should be displayed with expected title and copy
|
||||||
|
expect(find.text('End this group ride?'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.text('Ending the ride will stop live location sharing and mark the Group Ride as completed.'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tap CANCEL
|
||||||
|
await tester.tap(find.text('CANCEL'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Dialog dismissed, no service call made
|
||||||
|
expect(find.text('End this group ride?'), findsNothing);
|
||||||
|
expect(fakeService.updateRideStatusCallCount, equals(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tapping END RIDE in confirmation dialog triggers status update to completed',
|
||||||
|
(tester) async {
|
||||||
|
final fakeService = _FakeGroupRideService();
|
||||||
|
const user = AuthUser(
|
||||||
|
uid: 'organizer_alice',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
isAnonymous: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWithValue(user),
|
||||||
|
groupRideServiceProvider.overrideWithValue(fakeService),
|
||||||
|
groupRideStreamProvider('ride_test_123')
|
||||||
|
.overrideWith((ref) => Stream.value(underwayRide)),
|
||||||
|
groupRideParticipantsProvider('ride_test_123')
|
||||||
|
.overrideWith((ref) => Stream.value(participants)),
|
||||||
|
groupRideLivePresenceProvider('ride_test_123')
|
||||||
|
.overrideWith((ref) => Stream.value([])),
|
||||||
|
groupRideLiveSharingAllowedProvider.overrideWith((ref) => true),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: LiveGroupRideScreen(rideId: 'ride_test_123'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Tap Ride Options
|
||||||
|
await tester.tap(find.byTooltip('Ride Options'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Tap End Ride menu item
|
||||||
|
await tester.tap(find.text('End Ride'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Tap END RIDE button in dialog
|
||||||
|
await tester.tap(find.text('END RIDE'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify updateRideStatus was invoked with correct arguments
|
||||||
|
expect(fakeService.updateRideStatusCallCount, equals(1));
|
||||||
|
expect(fakeService.lastCallerUid, equals('organizer_alice'));
|
||||||
|
expect(fakeService.lastGroupRideId, equals('ride_test_123'));
|
||||||
|
expect(fakeService.lastNewStatus, equals(GroupRideStatus.completed));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue