556 lines
18 KiB
Dart
556 lines
18 KiB
Dart
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;
|
|
|
|
String _lastText = '';
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
if (widget.controller != null) {
|
|
_controller = widget.controller!;
|
|
} else {
|
|
_controller = TextEditingController(
|
|
text: _displayText(widget.initialPlace) ?? widget.initialQuery ?? '',
|
|
);
|
|
_internalController = true;
|
|
}
|
|
|
|
if (widget.focusNode != null) {
|
|
_focusNode = widget.focusNode!;
|
|
} else {
|
|
_focusNode = FocusNode();
|
|
_internalFocusNode = true;
|
|
}
|
|
|
|
_resolvedPlace = widget.initialPlace;
|
|
_lastText = _controller.text;
|
|
|
|
_focusNode.addListener(_handleFocusChange);
|
|
_controller.addListener(_handleControllerChange);
|
|
}
|
|
|
|
void _handleControllerChange() {
|
|
if (_controller.text != _lastText) {
|
|
_lastText = _controller.text;
|
|
_onTextChanged(_controller.text);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant AddressAutocompleteField oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (widget.initialPlace != oldWidget.initialPlace) {
|
|
_resolvedPlace = widget.initialPlace;
|
|
final newText = _displayText(widget.initialPlace) ?? '';
|
|
if (_controller.text != newText) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted && _controller.text != newText) {
|
|
_lastText = newText;
|
|
_controller.text = newText;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
String? _displayText(ResolvedPlace? place) {
|
|
final formatted = place?.formattedAddress?.trim();
|
|
return formatted == null || formatted.isEmpty
|
|
? place?.displayName
|
|
: formatted;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_debounceTimer?.cancel();
|
|
_controller.removeListener(_handleControllerChange);
|
|
_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: ', '');
|
|
|
|
final userMessage = msg.contains('STADIA_API_KEY')
|
|
? 'Address search is unavailable. The routing server needs a Stadia Maps API key.'
|
|
: msg;
|
|
|
|
setState(() {
|
|
_isLoading = false;
|
|
_errorMessage = userMessage.isNotEmpty
|
|
? userMessage
|
|
: '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;
|
|
final displayText = _displayText(place) ?? place.displayName;
|
|
_lastText = displayText;
|
|
_controller.text = displayText;
|
|
setState(() {
|
|
_isResolving = false;
|
|
_suggestions = const [];
|
|
_showDropdown = false;
|
|
_errorMessage = null;
|
|
});
|
|
_focusNode.unfocus();
|
|
widget.onPlaceResolved(place);
|
|
}
|
|
|
|
void _clear() {
|
|
_lastText = '';
|
|
_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 theme = Theme.of(context);
|
|
final onSurface = theme.colorScheme.onSurface;
|
|
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
|
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: TextStyle(color: onSurface, 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 : onSurfaceVariant,
|
|
fontSize: 13,
|
|
),
|
|
hintStyle: TextStyle(color: onSurfaceVariant.withValues(alpha: 0.5), 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: Icon(
|
|
Icons.clear,
|
|
size: 18,
|
|
color: onSurfaceVariant,
|
|
),
|
|
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) {
|
|
final theme = Theme.of(context);
|
|
final onSurface = theme.colorScheme.onSurface;
|
|
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
|
|
|
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),
|
|
Text(
|
|
'Searching addresses...',
|
|
style: TextStyle(color: onSurfaceVariant, 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: TextStyle(
|
|
color: onSurfaceVariant,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: () =>
|
|
_performSearch(_controller.text.trim()),
|
|
child: Text(
|
|
'RETRY',
|
|
style: TextStyle(
|
|
color: colors.cyberCyan,
|
|
fontSize: 11,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
else if (_hasSearched && _suggestions.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.location_off,
|
|
color: onSurfaceVariant.withValues(alpha: 0.5),
|
|
size: 16,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'No matching addresses found',
|
|
style: TextStyle(color: onSurfaceVariant, fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
else
|
|
for (int i = 0; i < _suggestions.length; i++) ...[
|
|
if (i > 0) Divider(height: 1, color: colors.outlineVariant),
|
|
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: TextStyle(
|
|
color: onSurface,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
subtitle: _suggestions[i].secondaryText.isNotEmpty
|
|
? Text(
|
|
_suggestions[i].secondaryText,
|
|
style: TextStyle(
|
|
color: onSurfaceVariant,
|
|
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: Text(
|
|
'Search by Stadia Maps / Pelias • © OpenStreetMap contributors',
|
|
style: TextStyle(
|
|
color: onSurfaceVariant.withValues(alpha: 0.6),
|
|
fontSize: 9,
|
|
letterSpacing: 0.2,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|