import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/database/app_database.dart'; import '../../../../core/utils/uuid.dart'; import '../../data/repositories/drift_pantry_repository.dart'; import '../../domain/entities/pantry_category.dart'; import '../../domain/entities/pantry_item.dart'; import '../../domain/repositories/pantry_repository.dart'; import '../../domain/value_objects/ingredient_name.dart'; /// Application-wide pantry repository (drift-backed). /// /// Overridable in widget/unit tests with an in-memory fake. final pantryRepositoryProvider = Provider( (ref) => DriftPantryRepository(ref.watch(appDatabaseProvider)), ); /// How the inventory list is organized on screen. enum PantrySortMode { category, expiration } /// Immutable snapshot of the pantry inventory state. class PantryInventoryState { const PantryInventoryState({ this.items = const [], this.sortMode = PantrySortMode.category, this.hasLoaded = false, this.errorMessage, }); /// Sentinel allowing `copyWith` to explicitly reset a field to `null`. static const Object _keep = Object(); /// Pantry rows (alphabetical, kept live by the repository watch stream). final List items; /// Active list organization mode (aisles vs. expiration). final PantrySortMode sortMode; /// `true` once the first watch emission arrived (drives the spinner). final bool hasLoaded; /// Last load/persist failure message. final String? errorMessage; /// Non-empty aisles in category order for sectioned rendering. Map> get groupedByCategory { final grouped = >{}; for (final category in PantryCategory.values) { final bucket = items .where((item) => item.category == category) .toList(growable: false); if (bucket.isNotEmpty) grouped[category] = bucket; } return grouped; } /// Items sorted soonest-expiration first; undated items sink to the bottom. List get byExpiration { final sorted = List.of(items); sorted.sort((a, b) { final aDate = a.expirationDate; final bDate = b.expirationDate; if (aDate == null && bDate == null) return a.name.compareTo(b.name); if (aDate == null) return 1; if (bDate == null) return -1; final byDate = aDate.compareTo(bDate); return byDate != 0 ? byDate : a.name.compareTo(b.name); }); return sorted; } /// Items already expired or inside the warning window (drives badges). List get expiringSoonItems { final flagged = []; for (final item in items) { final status = item.expirationStatus(); if (status == ExpirationStatus.expired || status == ExpirationStatus.expiringSoon) { flagged.add(item); } } return flagged; } PantryInventoryState copyWith({ List? items, PantrySortMode? sortMode, bool? hasLoaded, Object? errorMessage = _keep, }) => PantryInventoryState( items: items ?? this.items, sortMode: sortMode ?? this.sortMode, hasLoaded: hasLoaded ?? this.hasLoaded, errorMessage: identical(errorMessage, _keep) ? this.errorMessage : errorMessage as String?, ); } /// Orchestrates the pantry inventory: streams stock from the repository, /// supports quick increment/decrement adjustments, and owns the /// aisle/expiration presentation modes. class PantryInventoryController extends Notifier { @override PantryInventoryState build() { final subscription = ref .read(pantryRepositoryProvider) .watchItems() .listen( (items) => state = state.copyWith(items: items, hasLoaded: true), onError: (Object error, StackTrace stackTrace) { debugPrintStack( stackTrace: stackTrace, label: 'pantry inventory watch failed', ); state = state.copyWith(errorMessage: 'Could not load the pantry.'); }, ); ref.onDispose(subscription.cancel); return const PantryInventoryState(); } /// Adds a new stock row; [name] is canonicalized for fuzzy recipe matching. Future addItem({ required String name, required double quantityOnHand, required String unit, required PantryCategory category, DateTime? expirationDate, }) async { final key = canonicalIngredientName(name); if (key.isEmpty || quantityOnHand < 0) { state = state.copyWith(errorMessage: 'Enter a name and a valid quantity.'); return false; } try { await ref.read(pantryRepositoryProvider).saveItem( PantryItem( id: generateUuid(), name: key, quantityOnHand: quantityOnHand, unit: unit, category: category, expirationDate: expirationDate, lastUpdated: DateTime.now(), ), ); state = state.copyWith(errorMessage: null); return true; } catch (error, stackTrace) { debugPrintStack(stackTrace: stackTrace, label: 'pantry item save failed'); state = state.copyWith(errorMessage: 'Could not save the item.'); return false; } } /// Quick +/- stock adjustment for [item]; the level is clamped at zero. Future adjustQuantity(PantryItem item, double delta) async { final next = item.quantityOnHand + delta; try { await ref.read(pantryRepositoryProvider).saveItem( item.copyWith( quantityOnHand: next < 0 ? 0 : next, lastUpdated: DateTime.now(), ), ); } catch (error, stackTrace) { debugPrintStack( stackTrace: stackTrace, label: 'pantry quantity adjust failed', ); state = state.copyWith(errorMessage: 'Could not update the stock level.'); } } /// Removes the inventory row with [id]. Future deleteItem(String id) async { try { await ref.read(pantryRepositoryProvider).deleteItem(id); } catch (error, stackTrace) { debugPrintStack( stackTrace: stackTrace, label: 'pantry item delete failed', ); state = state.copyWith(errorMessage: 'Could not delete the item.'); } } /// Switches the list organization mode (aisles vs. expiration). void setSortMode(PantrySortMode mode) => state = state.copyWith(sortMode: mode); /// Clears the last error (e.g. once the user has seen it). void clearError() => state = state.copyWith(errorMessage: null); } /// Exposes the [PantryInventoryController] to the widget tree. final pantryInventoryControllerProvider = NotifierProvider( PantryInventoryController.new, );