511 lines
16 KiB
Dart
511 lines
16 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: widget.initialPlace?.displayName ?? 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 = widget.initialPlace?.displayName ?? '';
|
|
if (_controller.text != newText) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted && _controller.text != newText) {
|
|
_lastText = newText;
|
|
_controller.text = newText;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
@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: ', '');
|
|
|
|
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;
|
|
_lastText = place.displayName;
|
|
_controller.text = place.displayName;
|
|
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 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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|