import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/theme/theme_extensions.dart'; import '../../../services/map/map_provider_interface.dart'; import '../../../services/routes/route_provider.dart'; import '../../../services/routes/route_providers.dart'; /// Reusable address and place autocomplete field for MotorSignal. /// /// Integrates with [RouteProvider] (Stadia Maps / Pelias) to provide: /// - 350ms debounced search /// - Sequence-guarded asynchronous responses to prevent stale data /// - Immediate resolution when coordinates are present in search results /// - Invalidation of previous resolution upon text modification /// - Accessible suggestions list with loading, no-results, and error states class AddressAutocompleteField extends ConsumerStatefulWidget { final String labelText; final String? hintText; final ResolvedPlace? initialPlace; final String? initialQuery; final ValueChanged onPlaceResolved; final AppLatLng? nearLocation; final String? Function(String?)? validator; final bool enabled; final bool autofocus; final FocusNode? focusNode; final TextEditingController? controller; final Widget? prefixIcon; final bool showAttribution; final VoidCallback? onClear; final bool isRequired; const AddressAutocompleteField({ super.key, required this.labelText, this.hintText, this.initialPlace, this.initialQuery, required this.onPlaceResolved, this.nearLocation, this.validator, this.enabled = true, this.autofocus = false, this.focusNode, this.controller, this.prefixIcon, this.showAttribution = true, this.onClear, this.isRequired = false, }); @override ConsumerState createState() => _AddressAutocompleteFieldState(); } class _AddressAutocompleteFieldState extends ConsumerState { late final TextEditingController _controller; late final FocusNode _focusNode; bool _internalController = false; bool _internalFocusNode = false; ResolvedPlace? _resolvedPlace; List _suggestions = const []; bool _isLoading = false; bool _isResolving = false; String? _errorMessage; Timer? _debounceTimer; int _searchSequence = 0; bool _showDropdown = false; bool _hasSearched = false; 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 _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 _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, scrollPadding: const EdgeInsets.only(bottom: 120, top: 24), 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) Builder( builder: (context) { const attributionText = 'Search powered by Amazon Location Service • © AWS, HERE'; return Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 4, ), color: colors.surfaceContainer, child: Text( attributionText, style: TextStyle( color: onSurfaceVariant.withValues(alpha: 0.6), fontSize: 9, letterSpacing: 0.2, ), textAlign: TextAlign.center, ), ); }, ), ], ), ), ), ), ); } }