glp-companion/lib/features/nutrition/presentation/controllers/food_search_controller.dart

190 lines
5.7 KiB
Dart

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<IFoodSearchRepository>(
(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<FoodProduct> 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<FoodProduct>? 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<FoodSearchState> {
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<void> 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<void> retry() async {
final query = state.query.trim();
if (query.isEmpty) return;
if (_looksLikeBarcode(query)) {
await lookupBarcode(query);
} else {
await _runTextSearch(query);
}
}
Future<void> _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<FoodSearchController, FoodSearchState>(
(ref) => FoodSearchController(ref.watch(foodSearchRepositoryProvider)),
);