import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/database/app_database.dart'; import '../../data/repositories/food_search_repository.dart'; import '../../domain/entities/food_product.dart'; import '../../domain/repositories/food_search_repository.dart'; /// Application-wide food search repository (dual-source + drift cache). /// /// Overridable in widget/unit tests with an in-memory fake. final foodSearchRepositoryProvider = Provider( (ref) => FoodSearchRepository.defaults(ref.watch(appDatabaseProvider)), ); /// Debounce applied to keystroke-driven searches. const Duration kFoodSearchDebounce = Duration(milliseconds: 400); /// Immutable snapshot of the food-search screen state. class FoodSearchState { const FoodSearchState({ this.query = '', this.products = const [], this.isSearching = false, this.errorMessage, this.hasSearched = false, }); /// Active query text (kept in sync with the search field). final String query; /// Products matching [query] from the cache / Open Food Facts / USDA. final List products; /// Whether a lookup is currently in flight. final bool isSearching; /// Last lookup failure surfaced to the user; `null` when healthy. final String? errorMessage; /// Whether a lookup completed at least once (drives the empty state). final bool hasSearched; FoodSearchState copyWith({ String? query, List? products, bool? isSearching, Object? errorMessage = _keep, bool? hasSearched, }) => FoodSearchState( query: query ?? this.query, products: products ?? this.products, isSearching: isSearching ?? this.isSearching, errorMessage: identical(errorMessage, _keep) ? this.errorMessage : errorMessage as String?, hasSearched: hasSearched ?? this.hasSearched, ); /// Sentinel allowing `copyWith` to explicitly reset a field to `null`. static const Object _keep = Object(); } /// Orchestrates the food search flow: debounced text queries, direct barcode /// lookups, and the loading/error states consumed by [FoodSearchScreen]. /// /// Supersedes in-flight lookups via a monotonically increasing request token /// so a slow cache/network response can never overwrite a newer query. class FoodSearchController extends StateNotifier { FoodSearchController(this._repository) : super(const FoodSearchState()); final IFoodSearchRepository _repository; Timer? _debounce; int _requestToken = 0; @override void dispose() { _debounce?.cancel(); super.dispose(); } /// Updates the query text and schedules a debounced lookup. void onQueryChanged(String query) { _debounce?.cancel(); final trimmed = query.trim(); if (trimmed.isEmpty) { state = FoodSearchState(query: query); return; } state = state.copyWith(query: query, isSearching: true, errorMessage: null); _debounce = Timer(kFoodSearchDebounce, () => _runTextSearch(trimmed)); } /// Immediately resolves [barcode] (UPC/EAN from the scanner or manual /// entry), bypassing the debounce. Future lookupBarcode(String barcode) async { final trimmed = barcode.trim(); if (trimmed.isEmpty) return; _debounce?.cancel(); state = state.copyWith( query: trimmed, isSearching: true, errorMessage: null, ); final token = ++_requestToken; try { final results = await _repository.lookupBarcode(trimmed); if (!_isCurrent(token)) return; state = state.copyWith( products: results, isSearching: false, hasSearched: true, errorMessage: results.isEmpty ? 'No product found for "$trimmed".' : null, ); } catch (error, stackTrace) { if (!_isCurrent(token)) return; _recordError(error, stackTrace, 'barcode lookup failed'); } } /// Clears the query and results (search-bar "x" button). void clear() { _debounce?.cancel(); _requestToken++; state = const FoodSearchState(); } /// Re-runs the current query immediately (error-state retry / manual /// refresh). Future retry() async { final query = state.query.trim(); if (query.isEmpty) return; if (_looksLikeBarcode(query)) { await lookupBarcode(query); } else { await _runTextSearch(query); } } Future _runTextSearch(String query) async { final token = ++_requestToken; state = state.copyWith(isSearching: true, errorMessage: null); try { final results = await _repository.searchByText(query); if (!_isCurrent(token)) return; state = state.copyWith( products: results, isSearching: false, hasSearched: true, ); } catch (error, stackTrace) { if (!_isCurrent(token)) return; _recordError(error, stackTrace, 'text search failed'); } } bool _isCurrent(int token) => token == _requestToken && mounted; /// UPC-A/EAN-8/13/14 detection used to route typed queries through the /// barcode path. static bool _looksLikeBarcode(String query) => RegExp(r'^\d{8}$|^\d{12,14}$').hasMatch(query); void _recordError(Object error, StackTrace stackTrace, String label) { debugPrintStack(stackTrace: stackTrace, label: '$label failed'); state = state.copyWith( products: const [], isSearching: false, hasSearched: true, errorMessage: 'Search failed. Check your connection and try again.', ); } } /// Exposes the [FoodSearchController] to the widget tree. final foodSearchControllerProvider = StateNotifierProvider( (ref) => FoodSearchController(ref.watch(foodSearchRepositoryProvider)), );