190 lines
5.7 KiB
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)),
|
|
);
|