Flutter Search Localization: Multilingual Search UI, Filters, and Autocomplete
Build search experiences that work in any language. This guide covers localizing search inputs, autocomplete, filters, results, and empty states in Flutter.
Search Localization Challenges
Search features require localization for:
- Search hints - Placeholder text
- Autocomplete - Suggestions in user's language
- Filters - Sort options, categories
- Results - Count, relevance labels
- Empty states - No results messaging
Localized Search Bar
Complete Search Input
class LocalizedSearchBar extends StatefulWidget {
final Function(String) onSearch;
final Function(String)? onChanged;
final List<String>? suggestions;
final bool showVoiceInput;
const LocalizedSearchBar({
required this.onSearch,
this.onChanged,
this.suggestions,
this.showVoiceInput = true,
});
@override
State<LocalizedSearchBar> createState() => _LocalizedSearchBarState();
}
class _LocalizedSearchBarState extends State<LocalizedSearchBar> {
final _controller = TextEditingController();
final _focusNode = FocusNode();
bool _showSuggestions = false;
List<String> _filteredSuggestions = [];
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final isRtl = Directionality.of(context) == TextDirection.rtl;
return Column(
children: [
// Search field
Container(
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(12),
),
child: TextField(
controller: _controller,
focusNode: _focusNode,
textInputAction: TextInputAction.search,
textDirection: _getTextDirection(),
decoration: InputDecoration(
hintText: l10n.searchHint,
prefixIcon: Icon(Icons.search),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_controller.text.isNotEmpty)
IconButton(
icon: Icon(Icons.clear),
tooltip: l10n.clearSearch,
onPressed: _clearSearch,
),
if (widget.showVoiceInput)
IconButton(
icon: Icon(Icons.mic),
tooltip: l10n.voiceSearch,
onPressed: _startVoiceSearch,
),
],
),
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
onChanged: _onSearchChanged,
onSubmitted: widget.onSearch,
),
),
// Suggestions dropdown
if (_showSuggestions && _filteredSuggestions.isNotEmpty)
_buildSuggestions(l10n),
],
);
}
TextDirection? _getTextDirection() {
// Auto-detect text direction based on content
if (_controller.text.isEmpty) return null;
// Check if text starts with RTL character
final rtlRegex = RegExp(r'^[\u0600-\u06FF\u0750-\u077F\u0590-\u05FF]');
if (rtlRegex.hasMatch(_controller.text)) {
return TextDirection.rtl;
}
return TextDirection.ltr;
}
Widget _buildSuggestions(AppLocalizations l10n) {
return Container(
margin: EdgeInsets.only(top: 4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black12,
blurRadius: 8,
offset: Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.all(12),
child: Text(
l10n.suggestions,
style: TextStyle(
color: Colors.grey[600],
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
..._filteredSuggestions.take(5).map((suggestion) {
return ListTile(
leading: Icon(Icons.history, size: 20),
title: _highlightMatch(suggestion),
dense: true,
onTap: () => _selectSuggestion(suggestion),
);
}),
],
),
);
}
Widget _highlightMatch(String suggestion) {
final query = _controller.text.toLowerCase();
final index = suggestion.toLowerCase().indexOf(query);
if (index == -1) return Text(suggestion);
return RichText(
text: TextSpan(
style: TextStyle(color: Colors.black),
children: [
TextSpan(text: suggestion.substring(0, index)),
TextSpan(
text: suggestion.substring(index, index + query.length),
style: TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: suggestion.substring(index + query.length)),
],
),
);
}
void _onSearchChanged(String value) {
widget.onChanged?.call(value);
if (widget.suggestions != null) {
setState(() {
_showSuggestions = value.isNotEmpty;
_filteredSuggestions = widget.suggestions!
.where((s) => s.toLowerCase().contains(value.toLowerCase()))
.toList();
});
}
}
void _clearSearch() {
_controller.clear();
widget.onChanged?.call('');
setState(() {
_showSuggestions = false;
_filteredSuggestions = [];
});
}
void _selectSuggestion(String suggestion) {
_controller.text = suggestion;
widget.onSearch(suggestion);
setState(() => _showSuggestions = false);
}
void _startVoiceSearch() async {
final l10n = AppLocalizations.of(context)!;
// Implement voice search with localized prompts
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.listeningForSearch)),
);
}
}
Search Filters
Localized Filter Options
class LocalizedSearchFilters extends StatefulWidget {
final SearchFilters filters;
final Function(SearchFilters) onFiltersChanged;
const LocalizedSearchFilters({
required this.filters,
required this.onFiltersChanged,
});
@override
State<LocalizedSearchFilters> createState() => _LocalizedSearchFiltersState();
}
class _LocalizedSearchFiltersState extends State<LocalizedSearchFilters> {
late SearchFilters _filters;
@override
void initState() {
super.initState();
_filters = widget.filters;
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
// Sort dropdown
_buildSortChip(l10n),
SizedBox(width: 8),
// Category filter
_buildCategoryChip(l10n),
SizedBox(width: 8),
// Price range
_buildPriceChip(l10n),
SizedBox(width: 8),
// Date filter
_buildDateChip(l10n),
SizedBox(width: 8),
// Clear all
if (_filters.hasActiveFilters)
ActionChip(
label: Text(l10n.clearFilters),
onPressed: _clearAllFilters,
),
],
),
);
}
Widget _buildSortChip(AppLocalizations l10n) {
return PopupMenuButton<SortOption>(
child: Chip(
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(_getSortLabel(l10n)),
Icon(Icons.arrow_drop_down, size: 18),
],
),
backgroundColor: _filters.sortBy != SortOption.relevance
? Theme.of(context).primaryColor.withOpacity(0.1)
: null,
),
onSelected: (option) {
setState(() {
_filters = _filters.copyWith(sortBy: option);
});
widget.onFiltersChanged(_filters);
},
itemBuilder: (context) => [
PopupMenuItem(
value: SortOption.relevance,
child: Text(l10n.sortRelevance),
),
PopupMenuItem(
value: SortOption.newest,
child: Text(l10n.sortNewest),
),
PopupMenuItem(
value: SortOption.oldest,
child: Text(l10n.sortOldest),
),
PopupMenuItem(
value: SortOption.priceAsc,
child: Text(l10n.sortPriceLowHigh),
),
PopupMenuItem(
value: SortOption.priceDesc,
child: Text(l10n.sortPriceHighLow),
),
PopupMenuItem(
value: SortOption.popular,
child: Text(l10n.sortMostPopular),
),
PopupMenuItem(
value: SortOption.rating,
child: Text(l10n.sortHighestRated),
),
],
);
}
String _getSortLabel(AppLocalizations l10n) {
switch (_filters.sortBy) {
case SortOption.relevance:
return l10n.sortRelevance;
case SortOption.newest:
return l10n.sortNewest;
case SortOption.oldest:
return l10n.sortOldest;
case SortOption.priceAsc:
return l10n.sortPriceLowHigh;
case SortOption.priceDesc:
return l10n.sortPriceHighLow;
case SortOption.popular:
return l10n.sortMostPopular;
case SortOption.rating:
return l10n.sortHighestRated;
}
}
Widget _buildCategoryChip(AppLocalizations l10n) {
return FilterChip(
label: Text(_filters.category ?? l10n.allCategories),
selected: _filters.category != null,
onSelected: (_) => _showCategoryPicker(l10n),
);
}
void _showCategoryPicker(AppLocalizations l10n) {
showModalBottomSheet(
context: context,
builder: (context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.all(16),
child: Text(
l10n.selectCategory,
style: Theme.of(context).textTheme.titleLarge,
),
),
ListTile(
title: Text(l10n.allCategories),
trailing: _filters.category == null
? Icon(Icons.check)
: null,
onTap: () {
_updateCategory(null);
Navigator.pop(context);
},
),
..._getCategories(l10n).map((category) {
return ListTile(
title: Text(category.label),
trailing: _filters.category == category.id
? Icon(Icons.check)
: null,
onTap: () {
_updateCategory(category.id);
Navigator.pop(context);
},
);
}),
],
),
);
}
List<Category> _getCategories(AppLocalizations l10n) {
return [
Category(id: 'electronics', label: l10n.categoryElectronics),
Category(id: 'clothing', label: l10n.categoryClothing),
Category(id: 'books', label: l10n.categoryBooks),
Category(id: 'home', label: l10n.categoryHome),
Category(id: 'sports', label: l10n.categorySports),
];
}
void _updateCategory(String? category) {
setState(() {
_filters = _filters.copyWith(category: category);
});
widget.onFiltersChanged(_filters);
}
void _clearAllFilters() {
setState(() {
_filters = SearchFilters();
});
widget.onFiltersChanged(_filters);
}
}
class SearchFilters {
final SortOption sortBy;
final String? category;
final double? minPrice;
final double? maxPrice;
final DateTime? fromDate;
final DateTime? toDate;
SearchFilters({
this.sortBy = SortOption.relevance,
this.category,
this.minPrice,
this.maxPrice,
this.fromDate,
this.toDate,
});
bool get hasActiveFilters =>
sortBy != SortOption.relevance ||
category != null ||
minPrice != null ||
maxPrice != null ||
fromDate != null ||
toDate != null;
SearchFilters copyWith({
SortOption? sortBy,
String? category,
double? minPrice,
double? maxPrice,
DateTime? fromDate,
DateTime? toDate,
}) {
return SearchFilters(
sortBy: sortBy ?? this.sortBy,
category: category ?? this.category,
minPrice: minPrice ?? this.minPrice,
maxPrice: maxPrice ?? this.maxPrice,
fromDate: fromDate ?? this.fromDate,
toDate: toDate ?? this.toDate,
);
}
}
enum SortOption {
relevance,
newest,
oldest,
priceAsc,
priceDesc,
popular,
rating,
}
class Category {
final String id;
final String label;
Category({required this.id, required this.label});
}
Search Results
Localized Results Display
class LocalizedSearchResults extends StatelessWidget {
final List<SearchResult> results;
final String query;
final bool isLoading;
final VoidCallback? onLoadMore;
const LocalizedSearchResults({
required this.results,
required this.query,
this.isLoading = false,
this.onLoadMore,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context).toString();
if (isLoading && results.isEmpty) {
return _buildLoading(l10n);
}
if (results.isEmpty) {
return _buildEmptyState(l10n);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Results count
Padding(
padding: EdgeInsets.all(16),
child: Text(
_getResultsCountText(l10n, results.length),
style: TextStyle(color: Colors.grey[600]),
),
),
// Results list
Expanded(
child: ListView.builder(
itemCount: results.length + (onLoadMore != null ? 1 : 0),
itemBuilder: (context, index) {
if (index == results.length) {
return _buildLoadMoreButton(l10n);
}
return _buildResultItem(results[index], l10n, locale);
},
),
),
],
);
}
String _getResultsCountText(AppLocalizations l10n, int count) {
if (count == 0) {
return l10n.noResultsFor(query);
}
return l10n.resultsCount(count, query);
}
Widget _buildLoading(AppLocalizations l10n) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text(l10n.searchingFor(query)),
],
),
);
}
Widget _buildEmptyState(AppLocalizations l10n) {
return Center(
child: Padding(
padding: EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 64,
color: Colors.grey[400],
),
SizedBox(height: 16),
Text(
l10n.noResultsTitle,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 8),
Text(
l10n.noResultsFor(query),
style: TextStyle(color: Colors.grey[600]),
textAlign: TextAlign.center,
),
SizedBox(height: 24),
Text(
l10n.searchTips,
style: TextStyle(
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 8),
..._buildSearchTips(l10n),
],
),
),
);
}
List<Widget> _buildSearchTips(AppLocalizations l10n) {
final tips = [
l10n.searchTip1,
l10n.searchTip2,
l10n.searchTip3,
];
return tips.map((tip) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(Icons.lightbulb_outline, size: 16, color: Colors.amber),
SizedBox(width: 8),
Expanded(
child: Text(
tip,
style: TextStyle(
color: Colors.grey[600],
fontSize: 14,
),
),
),
],
),
);
}).toList();
}
Widget _buildResultItem(
SearchResult result,
AppLocalizations l10n,
String locale,
) {
return Card(
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
leading: result.imageUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
result.imageUrl!,
width: 56,
height: 56,
fit: BoxFit.cover,
),
)
: null,
title: Text(result.getTitle(locale)),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
result.getDescription(locale),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 4),
Row(
children: [
if (result.rating != null) ...[
Icon(Icons.star, size: 14, color: Colors.amber),
Text(' ${result.rating}'),
SizedBox(width: 8),
],
Text(
_formatDate(result.date, locale, l10n),
style: TextStyle(fontSize: 12),
),
],
),
],
),
isThreeLine: true,
onTap: () => _openResult(result),
),
);
}
String _formatDate(DateTime date, String locale, AppLocalizations l10n) {
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays == 0) {
return l10n.today;
} else if (difference.inDays == 1) {
return l10n.yesterday;
} else if (difference.inDays < 7) {
return l10n.daysAgo(difference.inDays);
} else {
return DateFormat.yMMMd(locale).format(date);
}
}
Widget _buildLoadMoreButton(AppLocalizations l10n) {
return Padding(
padding: EdgeInsets.all(16),
child: Center(
child: isLoading
? CircularProgressIndicator()
: TextButton(
onPressed: onLoadMore,
child: Text(l10n.loadMore),
),
),
);
}
void _openResult(SearchResult result) {
// Navigate to result detail
}
}
class SearchResult {
final String id;
final Map<String, String> titles;
final Map<String, String> descriptions;
final String? imageUrl;
final double? rating;
final DateTime date;
SearchResult({
required this.id,
required this.titles,
required this.descriptions,
this.imageUrl,
this.rating,
required this.date,
});
String getTitle(String locale) {
return titles[locale] ?? titles['en'] ?? titles.values.first;
}
String getDescription(String locale) {
return descriptions[locale] ?? descriptions['en'] ?? '';
}
}
ARB File Structure
{
"@@locale": "en",
"searchHint": "Search...",
"clearSearch": "Clear search",
"voiceSearch": "Voice search",
"listeningForSearch": "Listening...",
"suggestions": "Suggestions",
"sortRelevance": "Relevance",
"sortNewest": "Newest",
"sortOldest": "Oldest",
"sortPriceLowHigh": "Price: Low to High",
"sortPriceHighLow": "Price: High to Low",
"sortMostPopular": "Most Popular",
"sortHighestRated": "Highest Rated",
"allCategories": "All Categories",
"selectCategory": "Select Category",
"categoryElectronics": "Electronics",
"categoryClothing": "Clothing",
"categoryBooks": "Books",
"categoryHome": "Home & Garden",
"categorySports": "Sports & Outdoors",
"clearFilters": "Clear All",
"applyFilters": "Apply",
"resultsCount": "{count, plural, =1{1 result for \"{query}\"} other{{count} results for \"{query}\"}}",
"@resultsCount": {
"placeholders": {
"count": {"type": "int"},
"query": {"type": "String"}
}
},
"searchingFor": "Searching for \"{query}\"...",
"@searchingFor": {
"placeholders": {"query": {"type": "String"}}
},
"noResultsTitle": "No Results Found",
"noResultsFor": "We couldn't find anything for \"{query}\"",
"@noResultsFor": {
"placeholders": {"query": {"type": "String"}}
},
"searchTips": "Search tips:",
"searchTip1": "Check your spelling",
"searchTip2": "Try more general keywords",
"searchTip3": "Try fewer keywords",
"today": "Today",
"yesterday": "Yesterday",
"daysAgo": "{count, plural, =1{1 day ago} other{{count} days ago}}",
"@daysAgo": {
"placeholders": {"count": {"type": "int"}}
},
"loadMore": "Load More",
"showingResults": "Showing {shown} of {total}",
"@showingResults": {
"placeholders": {
"shown": {"type": "int"},
"total": {"type": "int"}
}
}
}
Conclusion
Search localization requires:
- Localized hints and placeholders
- RTL-aware text input
- Translated filter options
- Localized result counts with proper pluralization
- Helpful empty states in user's language
With proper localization, your search feature will help users find what they need in any language.