203 lines
6.8 KiB
Dart
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,
|
|
); |