← Back to Blog

Flutter SearchBar Localization: Hints, Suggestions, and No-Results Messages

fluttersearchbarsearchlocalizationsuggestionsfilters

Flutter SearchBar Localization: Hints, Suggestions, and No-Results Messages

Search functionality is central to many Flutter apps. Properly localizing search bars, hints, suggestions, and result messages ensures users worldwide can find what they're looking for. This comprehensive guide covers all aspects of search localization in Flutter.

Understanding Search Localization Components

A complete search experience includes:

  • Hint text: Placeholder prompting user to search
  • Suggestions: Predictive text or recent searches
  • No results message: When search returns empty
  • Result counts: Showing number of matches
  • Voice search prompt: For speech input
  • Filter labels: Refining search results

Basic SearchBar with Localized Hints

Start with a simple localized search bar:

import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class LocalizedSearchBar extends StatefulWidget {
  final ValueChanged<String> onSearch;

  const LocalizedSearchBar({
    super.key,
    required this.onSearch,
  });

  @override
  State<LocalizedSearchBar> createState() => _LocalizedSearchBarState();
}

class _LocalizedSearchBarState extends State<LocalizedSearchBar> {
  final _controller = TextEditingController();

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return SearchBar(
      controller: _controller,
      hintText: l10n.searchHint,
      leading: const Icon(Icons.search),
      trailing: [
        if (_controller.text.isNotEmpty)
          IconButton(
            icon: const Icon(Icons.clear),
            tooltip: l10n.clearSearchTooltip,
            onPressed: () {
              _controller.clear();
              widget.onSearch('');
            },
          ),
      ],
      onChanged: widget.onSearch,
    );
  }
}

ARB entries:

{
  "searchHint": "Search...",
  "@searchHint": {
    "description": "Placeholder text in search field"
  },
  "clearSearchTooltip": "Clear search",
  "@clearSearchTooltip": {
    "description": "Tooltip for clear search button"
  }
}

SearchAnchor with Suggestions

Implement a full search experience with suggestions:

class SearchWithSuggestions extends StatefulWidget {
  const SearchWithSuggestions({super.key});

  @override
  State<SearchWithSuggestions> createState() => _SearchWithSuggestionsState();
}

class _SearchWithSuggestionsState extends State<SearchWithSuggestions> {
  final _searchController = SearchController();
  List<String> _recentSearches = ['Flutter widgets', 'Localization', 'ARB files'];

  @override
  void dispose() {
    _searchController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return SearchAnchor(
      searchController: _searchController,
      builder: (context, controller) {
        return SearchBar(
          controller: controller,
          hintText: l10n.searchProductsHint,
          onTap: () => controller.openView(),
          onChanged: (_) => controller.openView(),
          leading: const Icon(Icons.search),
        );
      },
      suggestionsBuilder: (context, controller) {
        final query = controller.text.toLowerCase();

        if (query.isEmpty) {
          // Show recent searches
          return [
            Padding(
              padding: const EdgeInsets.all(16),
              child: Text(
                l10n.recentSearchesTitle,
                style: Theme.of(context).textTheme.titleSmall,
              ),
            ),
            ..._recentSearches.map((search) => ListTile(
              leading: const Icon(Icons.history),
              title: Text(search),
              trailing: IconButton(
                icon: const Icon(Icons.close),
                tooltip: l10n.removeFromHistory,
                onPressed: () {
                  setState(() {
                    _recentSearches.remove(search);
                  });
                },
              ),
              onTap: () {
                controller.closeView(search);
                _performSearch(search);
              },
            )),
            if (_recentSearches.isNotEmpty)
              TextButton(
                onPressed: () {
                  setState(() {
                    _recentSearches.clear();
                  });
                },
                child: Text(l10n.clearSearchHistory),
              ),
          ];
        }

        // Show filtered suggestions
        final suggestions = _getSuggestions(query, l10n);
        if (suggestions.isEmpty) {
          return [
            ListTile(
              leading: const Icon(Icons.search_off),
              title: Text(l10n.noSuggestionsFound),
              subtitle: Text(l10n.tryDifferentKeywords),
            ),
          ];
        }

        return suggestions.map((suggestion) => ListTile(
          leading: const Icon(Icons.search),
          title: _highlightMatch(suggestion, query, context),
          onTap: () {
            controller.closeView(suggestion);
            _performSearch(suggestion);
          },
        ));
      },
    );
  }

  List<String> _getSuggestions(String query, AppLocalizations l10n) {
    final allSuggestions = [
      l10n.suggestionWidgets,
      l10n.suggestionLocalization,
      l10n.suggestionARBFiles,
      l10n.suggestionL10n,
      l10n.suggestionIntl,
    ];

    return allSuggestions
        .where((s) => s.toLowerCase().contains(query))
        .toList();
  }

  Widget _highlightMatch(String text, String query, BuildContext context) {
    final index = text.toLowerCase().indexOf(query);
    if (index < 0) return Text(text);

    return RichText(
      text: TextSpan(
        style: Theme.of(context).textTheme.bodyLarge,
        children: [
          TextSpan(text: text.substring(0, index)),
          TextSpan(
            text: text.substring(index, index + query.length),
            style: const TextStyle(fontWeight: FontWeight.bold),
          ),
          TextSpan(text: text.substring(index + query.length)),
        ],
      ),
    );
  }

  void _performSearch(String query) {
    // Add to recent searches
    if (!_recentSearches.contains(query)) {
      setState(() {
        _recentSearches.insert(0, query);
        if (_recentSearches.length > 5) {
          _recentSearches.removeLast();
        }
      });
    }
    // Perform actual search...
  }
}

ARB entries:

{
  "searchProductsHint": "Search products...",
  "@searchProductsHint": {
    "description": "Hint text for product search"
  },
  "recentSearchesTitle": "Recent Searches",
  "@recentSearchesTitle": {
    "description": "Title for recent searches section"
  },
  "removeFromHistory": "Remove from history",
  "@removeFromHistory": {
    "description": "Tooltip for removing search from history"
  },
  "clearSearchHistory": "Clear search history",
  "@clearSearchHistory": {
    "description": "Button to clear all search history"
  },
  "noSuggestionsFound": "No suggestions found",
  "@noSuggestionsFound": {
    "description": "Message when no search suggestions match"
  },
  "tryDifferentKeywords": "Try different keywords",
  "@tryDifferentKeywords": {
    "description": "Suggestion to try different search terms"
  },
  "suggestionWidgets": "Flutter Widgets",
  "@suggestionWidgets": {
    "description": "Search suggestion"
  },
  "suggestionLocalization": "Localization Guide",
  "@suggestionLocalization": {
    "description": "Search suggestion"
  },
  "suggestionARBFiles": "ARB File Format",
  "@suggestionARBFiles": {
    "description": "Search suggestion"
  },
  "suggestionL10n": "L10n Best Practices",
  "@suggestionL10n": {
    "description": "Search suggestion"
  },
  "suggestionIntl": "Intl Package",
  "@suggestionIntl": {
    "description": "Search suggestion"
  }
}

Search Results with Counts

Display search results with localized counts:

class SearchResultsPage extends StatelessWidget {
  final String query;
  final List<SearchResult> results;
  final bool isLoading;

  const SearchResultsPage({
    super.key,
    required this.query,
    required this.results,
    required this.isLoading,
  });

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // Results header
        Padding(
          padding: const EdgeInsets.all(16),
          child: isLoading
              ? Text(l10n.searchingFor(query))
              : Text(
                  l10n.searchResultsCount(results.length, query),
                  style: Theme.of(context).textTheme.titleMedium,
                ),
        ),

        // Results list or empty state
        Expanded(
          child: isLoading
              ? const Center(child: CircularProgressIndicator())
              : results.isEmpty
                  ? _buildEmptyState(context, l10n)
                  : _buildResultsList(context, l10n),
        ),
      ],
    );
  }

  Widget _buildEmptyState(BuildContext context, AppLocalizations l10n) {
    return Center(
      child: Padding(
        padding: const EdgeInsets.all(32),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(
              Icons.search_off,
              size: 64,
              color: Theme.of(context).disabledColor,
            ),
            const SizedBox(height: 16),
            Text(
              l10n.noResultsFound,
              style: Theme.of(context).textTheme.headlineSmall,
              textAlign: TextAlign.center,
            ),
            const SizedBox(height: 8),
            Text(
              l10n.noResultsForQuery(query),
              style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                    color: Theme.of(context).disabledColor,
                  ),
              textAlign: TextAlign.center,
            ),
            const SizedBox(height: 24),
            Text(
              l10n.searchTips,
              style: Theme.of(context).textTheme.titleSmall,
            ),
            const SizedBox(height: 8),
            Text(
              l10n.searchTipCheckSpelling,
              style: Theme.of(context).textTheme.bodySmall,
            ),
            Text(
              l10n.searchTipUseFewerWords,
              style: Theme.of(context).textTheme.bodySmall,
            ),
            Text(
              l10n.searchTipTryGeneral,
              style: Theme.of(context).textTheme.bodySmall,
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildResultsList(BuildContext context, AppLocalizations l10n) {
    return ListView.builder(
      itemCount: results.length,
      itemBuilder: (context, index) {
        final result = results[index];
        return ListTile(
          title: Text(result.title),
          subtitle: Text(result.description),
          trailing: Text(
            l10n.relevanceScore(result.score),
            style: Theme.of(context).textTheme.bodySmall,
          ),
          onTap: () => _openResult(context, result),
        );
      },
    );
  }

  void _openResult(BuildContext context, SearchResult result) {
    // Navigate to result...
  }
}

ARB entries:

{
  "searchingFor": "Searching for \"{query}\"...",
  "@searchingFor": {
    "description": "Message shown while searching",
    "placeholders": {
      "query": {
        "type": "String",
        "example": "flutter widgets"
      }
    }
  },
  "searchResultsCount": "{count, plural, =0{No results} =1{1 result} other{{count} results}} for \"{query}\"",
  "@searchResultsCount": {
    "description": "Header showing number of search results",
    "placeholders": {
      "count": {
        "type": "int",
        "example": "42"
      },
      "query": {
        "type": "String",
        "example": "localization"
      }
    }
  },
  "noResultsFound": "No Results Found",
  "@noResultsFound": {
    "description": "Title for empty search results"
  },
  "noResultsForQuery": "We couldn't find anything matching \"{query}\"",
  "@noResultsForQuery": {
    "description": "Subtitle explaining no results for query",
    "placeholders": {
      "query": {
        "type": "String",
        "example": "flutter"
      }
    }
  },
  "searchTips": "Search tips:",
  "@searchTips": {
    "description": "Header for search tips list"
  },
  "searchTipCheckSpelling": "• Check your spelling",
  "@searchTipCheckSpelling": {
    "description": "Search tip about spelling"
  },
  "searchTipUseFewerWords": "• Try using fewer words",
  "@searchTipUseFewerWords": {
    "description": "Search tip about fewer words"
  },
  "searchTipTryGeneral": "• Try more general terms",
  "@searchTipTryGeneral": {
    "description": "Search tip about general terms"
  },
  "relevanceScore": "{score}% match",
  "@relevanceScore": {
    "description": "Relevance percentage for search result",
    "placeholders": {
      "score": {
        "type": "int",
        "example": "85"
      }
    }
  }
}

Search with Filters

Implement search with localized filter options:

class FilterableSearch extends StatefulWidget {
  const FilterableSearch({super.key});

  @override
  State<FilterableSearch> createState() => _FilterableSearchState();
}

class _FilterableSearchState extends State<FilterableSearch> {
  String _query = '';
  Set<String> _selectedFilters = {};
  RangeValues _priceRange = const RangeValues(0, 1000);
  String _sortBy = 'relevance';

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Column(
      children: [
        // Search bar
        Padding(
          padding: const EdgeInsets.all(16),
          child: SearchBar(
            hintText: l10n.searchAllProducts,
            leading: const Icon(Icons.search),
            trailing: [
              IconButton(
                icon: Badge(
                  isLabelVisible: _selectedFilters.isNotEmpty,
                  label: Text('${_selectedFilters.length}'),
                  child: const Icon(Icons.filter_list),
                ),
                tooltip: l10n.filterTooltip,
                onPressed: () => _showFilterSheet(context),
              ),
            ],
            onChanged: (value) => setState(() => _query = value),
          ),
        ),

        // Active filters chips
        if (_selectedFilters.isNotEmpty)
          SingleChildScrollView(
            scrollDirection: Axis.horizontal,
            padding: const EdgeInsets.symmetric(horizontal: 16),
            child: Row(
              children: [
                ..._selectedFilters.map((filter) => Padding(
                  padding: const EdgeInsets.only(right: 8),
                  child: Chip(
                    label: Text(_getFilterLabel(l10n, filter)),
                    onDeleted: () {
                      setState(() {
                        _selectedFilters.remove(filter);
                      });
                    },
                  ),
                )),
                ActionChip(
                  label: Text(l10n.clearAllFilters),
                  onPressed: () {
                    setState(() {
                      _selectedFilters.clear();
                    });
                  },
                ),
              ],
            ),
          ),

        // Sort options
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
          child: Row(
            children: [
              Text(l10n.sortBy),
              const SizedBox(width: 8),
              DropdownButton<String>(
                value: _sortBy,
                items: [
                  DropdownMenuItem(
                    value: 'relevance',
                    child: Text(l10n.sortRelevance),
                  ),
                  DropdownMenuItem(
                    value: 'price_low',
                    child: Text(l10n.sortPriceLow),
                  ),
                  DropdownMenuItem(
                    value: 'price_high',
                    child: Text(l10n.sortPriceHigh),
                  ),
                  DropdownMenuItem(
                    value: 'newest',
                    child: Text(l10n.sortNewest),
                  ),
                  DropdownMenuItem(
                    value: 'rating',
                    child: Text(l10n.sortRating),
                  ),
                ],
                onChanged: (value) {
                  if (value != null) {
                    setState(() => _sortBy = value);
                  }
                },
              ),
            ],
          ),
        ),

        // Results...
      ],
    );
  }

  void _showFilterSheet(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    showModalBottomSheet(
      context: context,
      isScrollControlled: true,
      builder: (context) => DraggableScrollableSheet(
        initialChildSize: 0.7,
        minChildSize: 0.5,
        maxChildSize: 0.95,
        expand: false,
        builder: (context, scrollController) => StatefulBuilder(
          builder: (context, setSheetState) => Column(
            children: [
              // Header
              Padding(
                padding: const EdgeInsets.all(16),
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: [
                    Text(
                      l10n.filterTitle,
                      style: Theme.of(context).textTheme.titleLarge,
                    ),
                    TextButton(
                      onPressed: () {
                        setSheetState(() {
                          _selectedFilters.clear();
                          _priceRange = const RangeValues(0, 1000);
                        });
                      },
                      child: Text(l10n.resetFilters),
                    ),
                  ],
                ),
              ),
              const Divider(),

              // Filter options
              Expanded(
                child: ListView(
                  controller: scrollController,
                  children: [
                    // Category filters
                    Padding(
                      padding: const EdgeInsets.all(16),
                      child: Text(
                        l10n.filterCategories,
                        style: Theme.of(context).textTheme.titleMedium,
                      ),
                    ),
                    Wrap(
                      spacing: 8,
                      children: ['electronics', 'clothing', 'books', 'home']
                          .map((cat) => FilterChip(
                                label: Text(_getCategoryLabel(l10n, cat)),
                                selected: _selectedFilters.contains(cat),
                                onSelected: (selected) {
                                  setSheetState(() {
                                    if (selected) {
                                      _selectedFilters.add(cat);
                                    } else {
                                      _selectedFilters.remove(cat);
                                    }
                                  });
                                },
                              ))
                          .toList(),
                    ),

                    // Price range
                    Padding(
                      padding: const EdgeInsets.all(16),
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Text(
                            l10n.filterPriceRange,
                            style: Theme.of(context).textTheme.titleMedium,
                          ),
                          const SizedBox(height: 8),
                          Text(
                            l10n.priceRangeLabel(
                              _priceRange.start.round(),
                              _priceRange.end.round(),
                            ),
                          ),
                          RangeSlider(
                            values: _priceRange,
                            min: 0,
                            max: 1000,
                            divisions: 20,
                            labels: RangeLabels(
                              '\$${_priceRange.start.round()}',
                              '\$${_priceRange.end.round()}',
                            ),
                            onChanged: (values) {
                              setSheetState(() => _priceRange = values);
                            },
                          ),
                        ],
                      ),
                    ),
                  ],
                ),
              ),

              // Apply button
              Padding(
                padding: const EdgeInsets.all(16),
                child: SizedBox(
                  width: double.infinity,
                  child: ElevatedButton(
                    onPressed: () {
                      setState(() {}); // Apply filters
                      Navigator.pop(context);
                    },
                    child: Text(l10n.applyFilters),
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  String _getFilterLabel(AppLocalizations l10n, String filter) {
    return _getCategoryLabel(l10n, filter);
  }

  String _getCategoryLabel(AppLocalizations l10n, String category) {
    switch (category) {
      case 'electronics':
        return l10n.categoryElectronics;
      case 'clothing':
        return l10n.categoryClothing;
      case 'books':
        return l10n.categoryBooks;
      case 'home':
        return l10n.categoryHome;
      default:
        return category;
    }
  }
}

ARB entries:

{
  "searchAllProducts": "Search all products...",
  "@searchAllProducts": {
    "description": "Hint for product search"
  },
  "filterTooltip": "Filter results",
  "@filterTooltip": {
    "description": "Tooltip for filter button"
  },
  "clearAllFilters": "Clear all",
  "@clearAllFilters": {
    "description": "Button to clear all active filters"
  },
  "sortBy": "Sort by:",
  "@sortBy": {
    "description": "Label for sort dropdown"
  },
  "sortRelevance": "Relevance",
  "@sortRelevance": {
    "description": "Sort by relevance option"
  },
  "sortPriceLow": "Price: Low to High",
  "@sortPriceLow": {
    "description": "Sort by price ascending"
  },
  "sortPriceHigh": "Price: High to Low",
  "@sortPriceHigh": {
    "description": "Sort by price descending"
  },
  "sortNewest": "Newest First",
  "@sortNewest": {
    "description": "Sort by date newest first"
  },
  "sortRating": "Highest Rated",
  "@sortRating": {
    "description": "Sort by rating"
  },
  "filterTitle": "Filters",
  "@filterTitle": {
    "description": "Title for filter sheet"
  },
  "resetFilters": "Reset",
  "@resetFilters": {
    "description": "Button to reset all filters"
  },
  "filterCategories": "Categories",
  "@filterCategories": {
    "description": "Section header for category filters"
  },
  "filterPriceRange": "Price Range",
  "@filterPriceRange": {
    "description": "Section header for price filter"
  },
  "priceRangeLabel": "${min} - ${max}",
  "@priceRangeLabel": {
    "description": "Label showing selected price range",
    "placeholders": {
      "min": {
        "type": "int",
        "example": "0"
      },
      "max": {
        "type": "int",
        "example": "1000"
      }
    }
  },
  "applyFilters": "Apply Filters",
  "@applyFilters": {
    "description": "Button to apply selected filters"
  },
  "categoryElectronics": "Electronics",
  "@categoryElectronics": {
    "description": "Electronics category label"
  },
  "categoryClothing": "Clothing",
  "@categoryClothing": {
    "description": "Clothing category label"
  },
  "categoryBooks": "Books",
  "@categoryBooks": {
    "description": "Books category label"
  },
  "categoryHome": "Home & Garden",
  "@categoryHome": {
    "description": "Home category label"
  }
}

Voice Search Support

Add voice search with localized prompts:

class VoiceSearchButton extends StatefulWidget {
  final ValueChanged<String> onVoiceResult;

  const VoiceSearchButton({
    super.key,
    required this.onVoiceResult,
  });

  @override
  State<VoiceSearchButton> createState() => _VoiceSearchButtonState();
}

class _VoiceSearchButtonState extends State<VoiceSearchButton> {
  bool _isListening = false;

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return IconButton(
      icon: Icon(
        _isListening ? Icons.mic : Icons.mic_none,
        color: _isListening
            ? Theme.of(context).colorScheme.primary
            : null,
      ),
      tooltip: _isListening
          ? l10n.voiceSearchListening
          : l10n.voiceSearchStart,
      onPressed: _toggleVoiceSearch,
    );
  }

  void _toggleVoiceSearch() async {
    final l10n = AppLocalizations.of(context)!;

    if (_isListening) {
      // Stop listening
      setState(() => _isListening = false);
      return;
    }

    // Show voice search dialog
    final result = await showDialog<String>(
      context: context,
      builder: (context) => _VoiceSearchDialog(l10n: l10n),
    );

    if (result != null && result.isNotEmpty) {
      widget.onVoiceResult(result);
    }
  }
}

class _VoiceSearchDialog extends StatefulWidget {
  final AppLocalizations l10n;

  const _VoiceSearchDialog({required this.l10n});

  @override
  State<_VoiceSearchDialog> createState() => _VoiceSearchDialogState();
}

class _VoiceSearchDialogState extends State<_VoiceSearchDialog>
    with SingleTickerProviderStateMixin {
  late AnimationController _animationController;
  String _recognizedText = '';
  bool _isListening = true;

  @override
  void initState() {
    super.initState();
    _animationController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 1000),
    )..repeat(reverse: true);
    _startListening();
  }

  @override
  void dispose() {
    _animationController.dispose();
    super.dispose();
  }

  void _startListening() {
    // Implement speech recognition here
    // For demo, simulate recognition after 2 seconds
    Future.delayed(const Duration(seconds: 2), () {
      if (mounted) {
        setState(() {
          _recognizedText = 'flutter localization';
          _isListening = false;
        });
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return AlertDialog(
      content: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          AnimatedBuilder(
            animation: _animationController,
            builder: (context, child) => Container(
              width: 80,
              height: 80,
              decoration: BoxDecoration(
                shape: BoxShape.circle,
                color: _isListening
                    ? Theme.of(context).colorScheme.primary.withOpacity(
                          0.3 + (_animationController.value * 0.3),
                        )
                    : Theme.of(context).colorScheme.primary.withOpacity(0.3),
              ),
              child: Icon(
                Icons.mic,
                size: 40,
                color: Theme.of(context).colorScheme.primary,
              ),
            ),
          ),
          const SizedBox(height: 24),
          Text(
            _isListening
                ? widget.l10n.voiceSearchPrompt
                : _recognizedText.isEmpty
                    ? widget.l10n.voiceSearchNoMatch
                    : widget.l10n.voiceSearchRecognized,
            style: Theme.of(context).textTheme.titleMedium,
            textAlign: TextAlign.center,
          ),
          if (_recognizedText.isNotEmpty) ...[
            const SizedBox(height: 8),
            Text(
              '"$_recognizedText"',
              style: Theme.of(context).textTheme.bodyLarge?.copyWith(
                    fontStyle: FontStyle.italic,
                  ),
            ),
          ],
        ],
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: Text(widget.l10n.cancelButton),
        ),
        if (_recognizedText.isNotEmpty)
          TextButton(
            onPressed: () => Navigator.pop(context, _recognizedText),
            child: Text(widget.l10n.searchButton),
          ),
      ],
    );
  }
}

ARB entries:

{
  "voiceSearchStart": "Start voice search",
  "@voiceSearchStart": {
    "description": "Tooltip for starting voice search"
  },
  "voiceSearchListening": "Listening...",
  "@voiceSearchListening": {
    "description": "Tooltip when voice search is active"
  },
  "voiceSearchPrompt": "Speak now...",
  "@voiceSearchPrompt": {
    "description": "Prompt shown during voice search"
  },
  "voiceSearchNoMatch": "Couldn't understand. Please try again.",
  "@voiceSearchNoMatch": {
    "description": "Message when voice not recognized"
  },
  "voiceSearchRecognized": "Search for:",
  "@voiceSearchRecognized": {
    "description": "Label before recognized text"
  },
  "searchButton": "Search",
  "@searchButton": {
    "description": "Button to perform search"
  }
}

Autocomplete Search

Implement autocomplete with debouncing:

class AutocompleteSearch extends StatefulWidget {
  final Future<List<String>> Function(String) fetchSuggestions;

  const AutocompleteSearch({
    super.key,
    required this.fetchSuggestions,
  });

  @override
  State<AutocompleteSearch> createState() => _AutocompleteSearchState();
}

class _AutocompleteSearchState extends State<AutocompleteSearch> {
  final _controller = TextEditingController();
  final _focusNode = FocusNode();
  Timer? _debounceTimer;
  List<String> _suggestions = [];
  bool _isLoading = false;
  String? _errorMessage;

  @override
  void dispose() {
    _controller.dispose();
    _focusNode.dispose();
    _debounceTimer?.cancel();
    super.dispose();
  }

  void _onQueryChanged(String query) {
    _debounceTimer?.cancel();

    if (query.isEmpty) {
      setState(() {
        _suggestions = [];
        _isLoading = false;
        _errorMessage = null;
      });
      return;
    }

    setState(() => _isLoading = true);

    _debounceTimer = Timer(const Duration(milliseconds: 300), () async {
      try {
        final suggestions = await widget.fetchSuggestions(query);
        if (mounted) {
          setState(() {
            _suggestions = suggestions;
            _isLoading = false;
            _errorMessage = null;
          });
        }
      } catch (e) {
        if (mounted) {
          final l10n = AppLocalizations.of(context)!;
          setState(() {
            _suggestions = [];
            _isLoading = false;
            _errorMessage = l10n.searchErrorMessage;
          });
        }
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Column(
      children: [
        TextField(
          controller: _controller,
          focusNode: _focusNode,
          decoration: InputDecoration(
            hintText: l10n.searchTypeToFind,
            prefixIcon: const Icon(Icons.search),
            suffixIcon: _isLoading
                ? const Padding(
                    padding: EdgeInsets.all(12),
                    child: SizedBox(
                      width: 24,
                      height: 24,
                      child: CircularProgressIndicator(strokeWidth: 2),
                    ),
                  )
                : _controller.text.isNotEmpty
                    ? IconButton(
                        icon: const Icon(Icons.clear),
                        onPressed: () {
                          _controller.clear();
                          _onQueryChanged('');
                        },
                      )
                    : null,
            border: const OutlineInputBorder(),
          ),
          onChanged: _onQueryChanged,
        ),

        // Error message
        if (_errorMessage != null)
          Padding(
            padding: const EdgeInsets.all(16),
            child: Row(
              children: [
                Icon(
                  Icons.error_outline,
                  color: Theme.of(context).colorScheme.error,
                ),
                const SizedBox(width: 8),
                Text(
                  _errorMessage!,
                  style: TextStyle(
                    color: Theme.of(context).colorScheme.error,
                  ),
                ),
              ],
            ),
          ),

        // Suggestions list
        if (_suggestions.isNotEmpty)
          Expanded(
            child: ListView.builder(
              itemCount: _suggestions.length,
              itemBuilder: (context, index) {
                final suggestion = _suggestions[index];
                return ListTile(
                  leading: const Icon(Icons.search),
                  title: Text(suggestion),
                  onTap: () {
                    _controller.text = suggestion;
                    _focusNode.unfocus();
                    // Perform search...
                  },
                );
              },
            ),
          ),

        // Empty state when typing but no suggestions
        if (_controller.text.isNotEmpty &&
            _suggestions.isEmpty &&
            !_isLoading &&
            _errorMessage == null)
          Padding(
            padding: const EdgeInsets.all(32),
            child: Column(
              children: [
                const Icon(Icons.search_off, size: 48),
                const SizedBox(height: 16),
                Text(
                  l10n.noSuggestionsFor(_controller.text),
                  textAlign: TextAlign.center,
                ),
              ],
            ),
          ),
      ],
    );
  }
}

ARB entries:

{
  "searchTypeToFind": "Type to find...",
  "@searchTypeToFind": {
    "description": "Hint for autocomplete search field"
  },
  "searchErrorMessage": "Search failed. Please try again.",
  "@searchErrorMessage": {
    "description": "Error message when search request fails"
  },
  "noSuggestionsFor": "No suggestions for \"{query}\"",
  "@noSuggestionsFor": {
    "description": "Message when autocomplete has no results",
    "placeholders": {
      "query": {
        "type": "String",
        "example": "xyz"
      }
    }
  }
}

Testing Localized Search

Write comprehensive tests for search functionality:

import 'package:flutter_test/flutter_test.dart';

void main() {
  group('Localized Search Tests', () {
    testWidgets('displays localized hint text', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          locale: const Locale('en'),
          home: Scaffold(
            body: LocalizedSearchBar(onSearch: (_) {}),
          ),
        ),
      );

      expect(find.text('Search...'), findsOneWidget);
    });

    testWidgets('shows localized no results message', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          locale: const Locale('en'),
          home: const Scaffold(
            body: SearchResultsPage(
              query: 'xyz',
              results: [],
              isLoading: false,
            ),
          ),
        ),
      );

      expect(find.text('No Results Found'), findsOneWidget);
      expect(
        find.text('We couldn\'t find anything matching "xyz"'),
        findsOneWidget,
      );
    });

    testWidgets('displays correct pluralized result count', (tester) async {
      final results = [
        SearchResult(title: 'Result 1', description: 'Desc', score: 90),
      ];

      await tester.pumpWidget(
        MaterialApp(
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          locale: const Locale('en'),
          home: Scaffold(
            body: SearchResultsPage(
              query: 'test',
              results: results,
              isLoading: false,
            ),
          ),
        ),
      );

      expect(find.text('1 result for "test"'), findsOneWidget);
    });

    testWidgets('shows recent searches section', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          locale: const Locale('en'),
          home: const Scaffold(
            body: SearchWithSuggestions(),
          ),
        ),
      );

      await tester.tap(find.byType(SearchBar));
      await tester.pumpAndSettle();

      expect(find.text('Recent Searches'), findsOneWidget);
    });
  });
}

Best Practices Summary

  1. Clear hint text: Tell users what they can search for
  2. Helpful suggestions: Show recent and popular searches
  3. Informative empty states: Provide tips when no results found
  4. Proper pluralization: Handle "1 result" vs "N results" correctly
  5. Filter labels: Localize all filter and sort options
  6. Voice prompts: Support voice search in user's language
  7. Error handling: Show clear error messages
  8. Loading states: Indicate when search is in progress

Conclusion

A well-localized search experience significantly improves user satisfaction in multilingual apps. By following these patterns, you ensure users can effectively find content regardless of their language.

Key takeaways:

  • Always localize hint text, suggestions, and results
  • Use proper pluralization for result counts
  • Provide helpful empty state messages
  • Support voice search with localized prompts
  • Test thoroughly across all supported locales