glp-companion/lib/features/pantry/presentation/controllers/pantry_inventory_controller...

203 lines
6.8 KiB
Dart

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<IPantryRepository>(
(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<PantryItem> 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<PantryCategory, List<PantryItem>> get groupedByCategory {
final grouped = <PantryCategory, List<PantryItem>>{};
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<PantryItem> get byExpiration {
final sorted = List<PantryItem>.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<PantryItem> get expiringSoonItems {
final flagged = <PantryItem>[];
for (final item in items) {
final status = item.expirationStatus();
if (status == ExpirationStatus.expired ||
status == ExpirationStatus.expiringSoon) {
flagged.add(item);
}
}
return flagged;
}
PantryInventoryState copyWith({
List<PantryItem>? 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<PantryInventoryState> {
@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<bool> 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<void> 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<void> 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, PantryInventoryState>(
PantryInventoryController.new,
);