MotorSignal-App/lib/features/routes/widgets/address_autocomplete_field....

563 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,
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,
),
);
},
),
],
),
),
),
),
);
}
}