← Back to Blog

Flutter Search UI Localization: Multilingual Search, Filters, and Autocomplete

fluttersearchautocompletefilterslocalizationui

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:

  1. Localized hints and placeholders
  2. RTL-aware text input
  3. Translated filter options
  4. Localized result counts with proper pluralization
  5. Helpful empty states in user's language

With proper localization, your search feature will help users find what they need in any language.

Related Resources