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