feat(pantry): complete Phase 5 pantry delta engine and auto-shopping generator
Domain (pure Dart): - Add PantryItem and ShoppingListItem entities plus the PantryCategory aisle enum (produce/meat_seafood/dairy/dry_goods/spices/other) with a keyword infer() heuristic and ExpirationStatus warning buckets (expired / expiringSoon within 3 days) - Add measurement_units value object (mass/volume/count dimensions with lb/oz/g/kg and ml/l/tsp/tbsp/cup conversion onto a shared base scale) and ingredient_name canonicalizer (lowercase, parenthetical stripping, plural folding: eggs -> egg, lbs -> lb) - Define IPantryRepository contract covering inventory and checklist CRUD - Add CalculateShoppingDelta use case: aggregates the active 7-day meal-plan slot window's recipe ingredient manifests by canonical name, computes deficit = totalRequired - currentPantryQuantity in base units (dominant recipe unit wins display, 2-decimal rounding), upserts isAutoGenerated rows with stable ids and source recipe titles, clears stale auto rows once stock covers the plan, and never modifies or removes manual rows Data: - Bump AppDatabase to schema v4 (migration for v1-v3 installs) with PantryItemsTable (UUID PK, canonical name, quantity/unit, aisle key, nullable expirationDate, lastUpdated) and ShoppingListItemsTable (UUID PK, required quantity/unit, purchased/auto flags, JSON sourceNotes for recipe titles) plus watch/get/upsert/delete helpers - Implement DriftPantryRepository (record <-> entity mapping, enum keys, JSON recipe-title round-trip) Presentation: - Add PantryInventoryController (live watch stream, quick +/- stock steps clamped at zero, aisle-grouping vs expiration-sort modes) and ShoppingListController (manual add, All/To Buy/Checked filter, check-off with "Add to Pantry?" unit-converting stock merge or aisle-inferred row creation, syncWithMealPlan reading the planner's Monday-Sunday window and recipe catalog) - Refactor PantryScreen: Inventory/Shopping tab header with remaining-count badge, Aisles/Expiration segmented control, categorized tiles with +/- counters and expired/expiring badges, swipe-to-delete on both lists, prominent "Sync with Meal Plan" banner with run summary + snackbar, "From: <recipe>" origin chips on auto rows, checklist filters, check-off -> pantry prompt, and Add Pantry Item / Add Shopping Item dialogs Testing: - 10 new tests in delta_calculator_test.dart: 300 g on hand vs 800 g requirement -> +500 g entry, sufficient stock -> no entry, manual rows preserved across repeated syncs, stale auto cleanup, cross-recipe plural/unit normalization (4 eggs + 2 egg - 2 -> +4 egg), lb/oz scale unification (0.75 lb), check-off -> pantry transfer (merge + aisle-inferred create), end-to-end syncWithMealPlan, and inventory canonicalization / quick-adjust clamp; full suite green (99/99) - flutter analyze clean (No issues found!) after codegen; BUILD_STATE.md: mark Phase 5 complete and record all verification gates
This commit is contained in:
parent
bdee2de35f
commit
747d917aed
|
|
@ -1,15 +1,15 @@
|
|||
# Build Execution Ledger
|
||||
|
||||
## Active Target
|
||||
- **Current Milestone:** Phase 4: Recipe Web Scraper Engine & Meal Planning Calendar — COMPLETE
|
||||
- **Status:** Phase 4 Complete (next: Phase 5)
|
||||
- **Current Milestone:** Phase 5: Pantry Delta Engine & Auto-Shopping Generator — COMPLETE
|
||||
- **Status:** Phase 5 Complete (next: Phase 6)
|
||||
|
||||
## Phase Checklist
|
||||
- [x] Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB)
|
||||
- [x] Phase 2: Medication Domain, Injection Rotation, Serum Modeling
|
||||
- [x] Phase 3: Nutrition, Protein Target Tracking, HealthGateway Sync
|
||||
- [x] Phase 4: Recipe Web Scraper Engine & Meal Planning Calendar
|
||||
- [ ] Phase 5: Pantry Delta Engine & Auto-Shopping Generator
|
||||
- [x] Phase 5: Pantry Delta Engine & Auto-Shopping Generator
|
||||
- [ ] Phase 6: Hardening, Native Desktop/Web Polish, CI/CD
|
||||
|
||||
## Phase 2 Deliverables (2026-10-16)
|
||||
|
|
@ -45,8 +45,16 @@
|
|||
## Post-Phase-4 Additions
|
||||
- [x] Desktop resize overflow fix (2026-10-16): the hatched yellow/black stripes (RenderFlex overflow) shown when shrinking the Windows window are gone — wide-mode day columns now scroll vertically (matching the compact pager), and a `LayoutBuilder` in `MealPlannerScreen` collapses the page into a single scrollable column (with a fixed-height calendar) whenever the body is shorter than ~520 dp. Covered by `test/features/meal_planner/meal_planner_screen_test.dart` (4 widget tests pumping the screen at short-compact, short-wide, tall, and extremely-short window sizes; overflow errors fail widget tests, so green proves no stripes).
|
||||
|
||||
## Phase 5 Deliverables (2026-10-16)
|
||||
- [x] Domain: `PantryItem` (+ `PantryCategory` aisle enum with `infer()` keyword heuristic, `ExpirationStatus` warning buckets), `ShoppingListItem`, `IPantryRepository`, and two pure-Dart value objects — `ingredient_name.dart` (lowercase, paren-stripped, plural-folded canonical keys: `eggs` → `egg`) and `measurement_units.dart` (mass/volume/count dimensions with lb↔oz↔g↔kg and ml/l/tsp/tbsp/cup conversion).
|
||||
- [x] Domain: `CalculateShoppingDelta` use case — aggregates the active 7-day `MealPlanSlot` window's `Recipe` ingredient manifests by canonical name, applies `deficit = totalRequired − currentPantryQuantity` in dimension base units (dominant recipe unit wins display, 2-decimal rounding), upserts `isAutoGenerated` rows with `sourceRecipeTitles` (stable ids, checked-state kept only while the requirement is unchanged), clears stale auto rows (satisfied by stock or superseded by a manual row), and never modifies or removes manual rows.
|
||||
- [x] Data: `AppDatabase` schema v4 (migration for v1–v3 installs) with `PantryItemsTable` (UUID PK, canonical name, quantity/unit, aisle key, nullable `expirationDate`, `lastUpdated`) and `ShoppingListItemsTable` (UUID PK, required quantity/unit, purchased/auto flags, JSON `sourceNotes` for recipe titles); drift-backed `DriftPantryRepository` (watch/get/upsert/delete for both lists, enum/JSON mapping).
|
||||
- [x] Presentation: `PantryInventoryController` (live watch stream, quick ± stock steps clamped at zero, aisle-grouping vs. expiration-sort modes) and `ShoppingListController` (manual add, All/To Buy/Checked filter, check-off with "Add to Pantry?" prompt performing a unit-converting stock merge or aisle-inferred row creation, `syncWithMealPlan` reading the planner's Monday–Sunday window + recipe catalog).
|
||||
- [x] Presentation: `PantryScreen` refactor — Inventory/Shopping tab header with remaining-count badge, Aisles/Expiration segmented control, categorized tiles with +/− counters and Expired/expiring badges, swipe-to-delete on both lists, prominent "Sync with Meal Plan" banner with run summary + snackbar, auto-row "From: <recipe>" origin chips, filter segments, check-off → pantry prompt, and Add Pantry Item / Add Shopping Item dialogs.
|
||||
- [x] Tests: 10 new tests in `test/features/pantry/delta_calculator_test.dart` — the three required scenarios (300 g vs 800 g → +500 g entry; sufficient stock → no entry; manual rows preserved across repeated syncs) plus stale auto cleanup, cross-recipe plural/unit normalization (4 eggs + 2 egg − 2 → +4 egg), lb/oz scale unification (0.75 lb), check-off → pantry transfer (merge + aisle-inferred create), end-to-end `syncWithMealPlan`, and inventory canonicalization/quick-adjust clamp.
|
||||
|
||||
## Active Verification Gates
|
||||
- [x] `flutter pub run build_runner build --delete-conflicting-outputs`
|
||||
- [x] `flutter analyze` (Zero issues — `No issues found!`)
|
||||
- [x] `flutter test` (89/89 passing)
|
||||
- [x] `flutter test` (99/99 passing)
|
||||
- [x] `flutter build web --no-tree-shake-icons` (conditional import keeps `package:health`/`dart:io` out of the web bundle)
|
||||
|
|
@ -177,6 +177,71 @@ class MealPlanSlotsTable extends Table {
|
|||
Set<Column>? get primaryKey => {id};
|
||||
}
|
||||
|
||||
/// A pantry stock record: how much of an ingredient is on hand, its grocery
|
||||
/// aisle, and when it expires.
|
||||
///
|
||||
/// [name] stores the canonical lowercase ingredient key (see
|
||||
/// `features/pantry/domain/value_objects/ingredient_name.dart`) so the delta
|
||||
/// engine can fuzzy-match recipe lines against stock. The `core` layer never
|
||||
/// imports feature code.
|
||||
@DataClassName('PantryItemRecord')
|
||||
class PantryItemsTable extends Table {
|
||||
/// UUID primary key (see `core/utils/uuid.dart`).
|
||||
TextColumn get id => text()();
|
||||
|
||||
/// Canonical lowercase ingredient name (e.g. `chicken breast`).
|
||||
TextColumn get name => text().withLength(min: 1, max: 128)();
|
||||
|
||||
/// Quantity currently on hand, expressed in [unit].
|
||||
RealColumn get quantityOnHand => real()();
|
||||
|
||||
/// Canonical unit key (e.g. `g`, `oz`, `count`).
|
||||
TextColumn get unit => text().withLength(min: 1, max: 16)();
|
||||
|
||||
/// Grocery aisle key (e.g. `meat_seafood`) — the `persistenceKey` of the
|
||||
/// pantry domain's category enum.
|
||||
TextColumn get category => text().withLength(min: 1, max: 32)();
|
||||
|
||||
/// Best-by date; `null` when the item does not expire.
|
||||
DateTimeColumn get expirationDate => dateTime().nullable()();
|
||||
|
||||
DateTimeColumn get lastUpdated => dateTime()();
|
||||
|
||||
@override
|
||||
Set<Column>? get primaryKey => {id};
|
||||
}
|
||||
|
||||
/// One row of the shopping checklist.
|
||||
///
|
||||
/// Rows flagged [isAutoGenerated] are owned by the delta engine and
|
||||
/// recomputed on every meal-plan sync; manual rows are never touched by
|
||||
/// syncs.
|
||||
@DataClassName('ShoppingListItemRecord')
|
||||
class ShoppingListItemsTable extends Table {
|
||||
/// UUID primary key.
|
||||
TextColumn get id => text()();
|
||||
|
||||
TextColumn get name => text().withLength(min: 1, max: 128)();
|
||||
|
||||
/// Amount needed, expressed in [unit].
|
||||
RealColumn get requiredQuantity => real()();
|
||||
|
||||
/// Canonical unit key (e.g. `g`, `count`).
|
||||
TextColumn get unit => text().withLength(min: 1, max: 16)();
|
||||
|
||||
BoolColumn get isPurchased => boolean().withDefault(const Constant(false))();
|
||||
|
||||
BoolColumn get isAutoGenerated =>
|
||||
boolean().withDefault(const Constant(false))();
|
||||
|
||||
/// JSON-encoded `List<String>` of contributing recipe titles for
|
||||
/// auto-generated rows; `null` for manual entries.
|
||||
TextColumn get sourceNotes => text().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column>? get primaryKey => {id};
|
||||
}
|
||||
|
||||
/// Root drift database for the GLP-1 Companion.
|
||||
///
|
||||
/// On native platforms the connection is backed by SQLCipher: the 256-bit AES
|
||||
|
|
@ -192,6 +257,8 @@ class MealPlanSlotsTable extends Table {
|
|||
BiometricLogsTable,
|
||||
RecipesTable,
|
||||
MealPlanSlotsTable,
|
||||
PantryItemsTable,
|
||||
ShoppingListItemsTable,
|
||||
],
|
||||
)
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
|
|
@ -202,7 +269,7 @@ class AppDatabase extends _$AppDatabase {
|
|||
AppDatabase.forTesting(super.executor);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 3;
|
||||
int get schemaVersion => 4;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
|
|
@ -221,6 +288,11 @@ class AppDatabase extends _$AppDatabase {
|
|||
await m.createTable(recipesTable);
|
||||
await m.createTable(mealPlanSlotsTable);
|
||||
}
|
||||
// v4 added the pantry inventory and shopping checklist.
|
||||
if (from < 4) {
|
||||
await m.createTable(pantryItemsTable);
|
||||
await m.createTable(shoppingListItemsTable);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -585,6 +657,76 @@ class AppDatabase extends _$AppDatabase {
|
|||
(t) => OrderingTerm.asc(t.plannedDate),
|
||||
(t) => OrderingTerm.asc(t.id),
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pantry inventory CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Records/companions only: mapping to the pantry feature's `PantryItem`
|
||||
// entity happens in `features/pantry/data/repositories/`.
|
||||
|
||||
/// Emits pantry stock (alphabetical) and re-emits on every write.
|
||||
Stream<List<PantryItemRecord>> watchPantryItems() =>
|
||||
_selectPantryItems().watch();
|
||||
|
||||
/// One-shot snapshot of pantry stock (alphabetical).
|
||||
Future<List<PantryItemRecord>> getPantryItems() => _selectPantryItems().get();
|
||||
|
||||
/// The pantry row with [id], or `null` when absent.
|
||||
Future<PantryItemRecord?> getPantryItemById(String id) =>
|
||||
(select(pantryItemsTable)..where((t) => t.id.equals(id)))
|
||||
.getSingleOrNull();
|
||||
|
||||
/// Inserts the pantry row or updates the existing row with the same id.
|
||||
Future<void> upsertPantryItem(PantryItemsTableCompanion entry) =>
|
||||
into(pantryItemsTable).insertOnConflictUpdate(entry);
|
||||
|
||||
/// Deletes the pantry row with [id], if present.
|
||||
Future<int> deletePantryItem(String id) =>
|
||||
(delete(pantryItemsTable)..where((t) => t.id.equals(id))).go();
|
||||
|
||||
/// Alphabetical query shared by the watch/get helpers.
|
||||
SimpleSelectStatement<$PantryItemsTableTable, PantryItemRecord>
|
||||
_selectPantryItems() => select(pantryItemsTable)
|
||||
..orderBy([
|
||||
(t) => OrderingTerm.asc(t.name),
|
||||
(t) => OrderingTerm.asc(t.id),
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shopping list CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Emits the shopping checklist (unchecked rows first) and re-emits on
|
||||
/// every write.
|
||||
Stream<List<ShoppingListItemRecord>> watchShoppingListItems() =>
|
||||
_selectShoppingListItems().watch();
|
||||
|
||||
/// One-shot snapshot of the shopping checklist (unchecked first).
|
||||
Future<List<ShoppingListItemRecord>> getShoppingListItems() =>
|
||||
_selectShoppingListItems().get();
|
||||
|
||||
/// The checklist row with [id], or `null` when absent.
|
||||
Future<ShoppingListItemRecord?> getShoppingListItemById(String id) =>
|
||||
(select(shoppingListItemsTable)..where((t) => t.id.equals(id)))
|
||||
.getSingleOrNull();
|
||||
|
||||
/// Inserts the checklist row or updates the existing row with the same id.
|
||||
Future<void> upsertShoppingListItem(ShoppingListItemsTableCompanion entry) =>
|
||||
into(shoppingListItemsTable).insertOnConflictUpdate(entry);
|
||||
|
||||
/// Deletes the checklist row with [id], if present.
|
||||
Future<int> deleteShoppingListItem(String id) =>
|
||||
(delete(shoppingListItemsTable)..where((t) => t.id.equals(id))).go();
|
||||
|
||||
/// Purchase-state query shared by the watch/get helpers.
|
||||
SimpleSelectStatement<$ShoppingListItemsTableTable, ShoppingListItemRecord>
|
||||
_selectShoppingListItems() => select(shoppingListItemsTable)
|
||||
..orderBy([
|
||||
(t) => OrderingTerm.asc(t.isPurchased),
|
||||
(t) => OrderingTerm.asc(t.name),
|
||||
(t) => OrderingTerm.asc(t.id),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Application-wide database instance; closed when the container is disposed.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,143 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
import '../../../../core/database/app_database.dart';
|
||||
import '../../domain/entities/pantry_category.dart';
|
||||
import '../../domain/entities/pantry_item.dart';
|
||||
import '../../domain/entities/shopping_list_item.dart';
|
||||
import '../../domain/repositories/pantry_repository.dart';
|
||||
|
||||
/// Drift-backed implementation of [IPantryRepository].
|
||||
///
|
||||
/// Owns the translation between persistence records ([PantryItemRecord],
|
||||
/// [ShoppingListItemRecord]) and the pure-Dart pantry entities. The aisle
|
||||
/// persists as its stable enum key and auto-generated recipe sources
|
||||
/// round-trip through a JSON column, so the core schema never imports
|
||||
/// feature code.
|
||||
class DriftPantryRepository implements IPantryRepository {
|
||||
DriftPantryRepository(this._database);
|
||||
|
||||
final AppDatabase _database;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pantry inventory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@override
|
||||
Stream<List<PantryItem>> watchItems() => _database
|
||||
.watchPantryItems()
|
||||
.map((records) => records.map(_mapItem).toList(growable: false));
|
||||
|
||||
@override
|
||||
Future<List<PantryItem>> getItems() async =>
|
||||
(await _database.getPantryItems()).map(_mapItem).toList(growable: false);
|
||||
|
||||
@override
|
||||
Future<PantryItem?> getItem(String id) async {
|
||||
final record = await _database.getPantryItemById(id);
|
||||
return record == null ? null : _mapItem(record);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PantryItem> saveItem(PantryItem item) async {
|
||||
await _database.upsertPantryItem(
|
||||
PantryItemsTableCompanion(
|
||||
id: Value(item.id),
|
||||
name: Value(item.name),
|
||||
quantityOnHand: Value(item.quantityOnHand),
|
||||
unit: Value(item.unit),
|
||||
category: Value(item.category.persistenceKey),
|
||||
expirationDate: Value(item.expirationDate),
|
||||
lastUpdated: Value(item.lastUpdated),
|
||||
),
|
||||
);
|
||||
return item;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteItem(String id) => _database.deletePantryItem(id);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shopping list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@override
|
||||
Stream<List<ShoppingListItem>> watchShoppingList() => _database
|
||||
.watchShoppingListItems()
|
||||
.map((records) => records.map(_mapShoppingItem).toList(growable: false));
|
||||
|
||||
@override
|
||||
Future<List<ShoppingListItem>> getShoppingList() async =>
|
||||
(await _database.getShoppingListItems())
|
||||
.map(_mapShoppingItem)
|
||||
.toList(growable: false);
|
||||
|
||||
@override
|
||||
Future<ShoppingListItem?> getShoppingListItem(String id) async {
|
||||
final record = await _database.getShoppingListItemById(id);
|
||||
return record == null ? null : _mapShoppingItem(record);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ShoppingListItem> saveShoppingListItem(ShoppingListItem item) async {
|
||||
await _database.upsertShoppingListItem(
|
||||
ShoppingListItemsTableCompanion(
|
||||
id: Value(item.id),
|
||||
name: Value(item.name),
|
||||
requiredQuantity: Value(item.requiredQuantity),
|
||||
unit: Value(item.unit),
|
||||
isPurchased: Value(item.isPurchased),
|
||||
isAutoGenerated: Value(item.isAutoGenerated),
|
||||
sourceNotes: Value(
|
||||
item.sourceRecipeTitles.isEmpty
|
||||
? null
|
||||
: jsonEncode(item.sourceRecipeTitles),
|
||||
),
|
||||
),
|
||||
);
|
||||
return item;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteShoppingListItem(String id) =>
|
||||
_database.deleteShoppingListItem(id);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
PantryItem _mapItem(PantryItemRecord record) => PantryItem(
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
quantityOnHand: record.quantityOnHand,
|
||||
unit: record.unit,
|
||||
category: PantryCategory.parse(record.category),
|
||||
expirationDate: record.expirationDate,
|
||||
lastUpdated: record.lastUpdated,
|
||||
);
|
||||
|
||||
ShoppingListItem _mapShoppingItem(ShoppingListItemRecord record) =>
|
||||
ShoppingListItem(
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
requiredQuantity: record.requiredQuantity,
|
||||
unit: record.unit,
|
||||
isPurchased: record.isPurchased,
|
||||
isAutoGenerated: record.isAutoGenerated,
|
||||
sourceRecipeTitles: _decodeSources(record.sourceNotes),
|
||||
);
|
||||
|
||||
/// Decodes the JSON recipe-title list; malformed or absent payloads fall
|
||||
/// back to an empty list (manual rows carry `null`).
|
||||
List<String> _decodeSources(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return const [];
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List) return const [];
|
||||
return decoded.map((title) => title.toString()).toList(growable: false);
|
||||
} on FormatException {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
/// Grocery-aisle taxonomy for pantry inventory and generated shopping items.
|
||||
///
|
||||
/// Pure Dart (no framework imports). The enum persists via
|
||||
/// [persistenceKey] (e.g. `meat_seafood`) so the drift schema stores a
|
||||
/// stable string decoupled from the Dart identifier.
|
||||
enum PantryCategory {
|
||||
produce('produce'),
|
||||
meatSeafood('meat_seafood'),
|
||||
dairy('dairy'),
|
||||
dryGoods('dry_goods'),
|
||||
spices('spices'),
|
||||
other('other');
|
||||
|
||||
const PantryCategory(this.persistenceKey);
|
||||
|
||||
/// Stable string persisted in the database; never changes across renames.
|
||||
final String persistenceKey;
|
||||
|
||||
/// Human-readable label for grouping headers and pickers.
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case PantryCategory.produce:
|
||||
return 'Produce';
|
||||
case PantryCategory.meatSeafood:
|
||||
return 'Meat & Seafood';
|
||||
case PantryCategory.dairy:
|
||||
return 'Dairy & Eggs';
|
||||
case PantryCategory.dryGoods:
|
||||
return 'Dry Goods';
|
||||
case PantryCategory.spices:
|
||||
return 'Spices & Seasonings';
|
||||
case PantryCategory.other:
|
||||
return 'Other';
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a persisted key; unknown/legacy values fall back to
|
||||
/// [PantryCategory.other] so legacy rows never break the pantry list.
|
||||
static PantryCategory parse(String? raw) {
|
||||
for (final category in values) {
|
||||
if (category.persistenceKey == raw) return category;
|
||||
}
|
||||
return PantryCategory.other;
|
||||
}
|
||||
|
||||
/// Best-effort aisle guess used when a checked-off shopping item is
|
||||
/// transferred into the pantry without a user-selected category. Rules are
|
||||
/// evaluated in order, so more specific families (meat, dairy) win over
|
||||
/// broad ones (dry goods).
|
||||
static PantryCategory infer(String name) {
|
||||
final lower = name.toLowerCase();
|
||||
if (lower.isEmpty) return PantryCategory.other;
|
||||
for (final rule in _inferenceRules) {
|
||||
if (rule.keywords.any(lower.contains)) return rule.category;
|
||||
}
|
||||
return PantryCategory.other;
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryRule {
|
||||
const _CategoryRule(this.category, this.keywords);
|
||||
|
||||
final PantryCategory category;
|
||||
final List<String> keywords;
|
||||
}
|
||||
|
||||
const List<_CategoryRule> _inferenceRules = [
|
||||
_CategoryRule(PantryCategory.meatSeafood, [
|
||||
'chicken', 'beef', 'pork', 'turkey', 'lamb', 'steak', 'bacon', 'sausage',
|
||||
'salmon', 'tuna', 'shrimp', 'prawn', 'fish', 'crab', 'lobster', 'scallop',
|
||||
'deli meat', 'ground meat',
|
||||
]),
|
||||
_CategoryRule(PantryCategory.dairy, [
|
||||
'yogurt', 'yoghurt', 'milk', 'cheese', 'butter', 'cream', 'egg',
|
||||
'kefir', 'ricotta', 'mozzarella',
|
||||
]),
|
||||
_CategoryRule(PantryCategory.produce, [
|
||||
'avocado', 'tomato', 'onion', 'garlic', 'lettuce', 'spinach', 'kale',
|
||||
'broccoli', 'carrot', 'potato', 'lemon', 'lime', 'apple', 'banana',
|
||||
'berry', 'grape', 'cucumber', 'mushroom', 'pepper', 'zucchini',
|
||||
'celery', 'basil', 'cilantro', 'ginger', 'salad', 'fruit',
|
||||
]),
|
||||
_CategoryRule(PantryCategory.spices, [
|
||||
'salt', 'cumin', 'paprika', 'oregano', 'thyme', 'rosemary', 'cinnamon',
|
||||
'turmeric', 'saffron', 'seasoning', 'spice', 'cloves',
|
||||
]),
|
||||
_CategoryRule(PantryCategory.dryGoods, [
|
||||
'flour', 'sugar', 'rice', 'pasta', 'noodle', 'bread', 'oat', 'lentil',
|
||||
'bean', 'cereal', 'quinoa', 'sauce', 'vinegar', 'cocoa', 'coffee',
|
||||
'honey', 'syrup', 'broth', 'stock',
|
||||
]),
|
||||
];
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import '../../../../core/utils/date_time_utils.dart';
|
||||
import 'pantry_category.dart';
|
||||
|
||||
/// Expiration bucket used by the pantry list to render warning badges.
|
||||
enum ExpirationStatus {
|
||||
/// The item has no expiration date recorded.
|
||||
none,
|
||||
|
||||
/// Comfortably far from expiring.
|
||||
fresh,
|
||||
|
||||
/// Within [PantryItem.expirationWarningDays] of expiring.
|
||||
expiringSoon,
|
||||
|
||||
/// Already past its expiration date.
|
||||
expired,
|
||||
}
|
||||
|
||||
/// Immutable on-hand inventory record for one ingredient.
|
||||
///
|
||||
/// Pure Dart (no framework imports) so the entity can be shared by the delta
|
||||
/// use case, the drift-backed repository, and the Riverpod presentation
|
||||
/// layer without leaking infrastructure concerns into the domain.
|
||||
class PantryItem {
|
||||
const PantryItem({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.quantityOnHand,
|
||||
required this.unit,
|
||||
required this.category,
|
||||
this.expirationDate,
|
||||
required this.lastUpdated,
|
||||
});
|
||||
|
||||
/// Days ahead of today that trigger an `expiringSoon` warning badge.
|
||||
static const int expirationWarningDays = 3;
|
||||
|
||||
/// UUID primary key assigned when the row is first created.
|
||||
final String id;
|
||||
|
||||
/// Canonical, lowercase ingredient key (see
|
||||
/// `value_objects/ingredient_name.dart`) used for fuzzy recipe matching,
|
||||
/// e.g. `chicken breast`.
|
||||
final String name;
|
||||
|
||||
/// Quantity currently on hand, expressed in [unit].
|
||||
final double quantityOnHand;
|
||||
|
||||
/// Canonical unit key (e.g. `g`, `oz`, `count`).
|
||||
final String unit;
|
||||
|
||||
/// Grocery aisle the item belongs to.
|
||||
final PantryCategory category;
|
||||
|
||||
/// Best-by date; `null` when the item does not expire.
|
||||
final DateTime? expirationDate;
|
||||
|
||||
/// When the row was last written (drives change tracking).
|
||||
final DateTime lastUpdated;
|
||||
|
||||
/// Warning bucket for [now] (defaults to the current instant).
|
||||
ExpirationStatus expirationStatus({DateTime? now}) {
|
||||
final date = expirationDate;
|
||||
if (date == null) return ExpirationStatus.none;
|
||||
final today = startOfDay(now ?? DateTime.now());
|
||||
final expiry = startOfDay(date);
|
||||
if (expiry.isBefore(today)) return ExpirationStatus.expired;
|
||||
if (expiry.difference(today).inDays <= expirationWarningDays) {
|
||||
return ExpirationStatus.expiringSoon;
|
||||
}
|
||||
return ExpirationStatus.fresh;
|
||||
}
|
||||
|
||||
/// Whole days until expiration (0 = today, negative = overdue); `null`
|
||||
/// when no expiration date is recorded.
|
||||
int? daysUntilExpiration({DateTime? now}) {
|
||||
final date = expirationDate;
|
||||
if (date == null) return null;
|
||||
return startOfDay(date)
|
||||
.difference(startOfDay(now ?? DateTime.now()))
|
||||
.inDays;
|
||||
}
|
||||
|
||||
PantryItem copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
double? quantityOnHand,
|
||||
String? unit,
|
||||
PantryCategory? category,
|
||||
DateTime? expirationDate,
|
||||
DateTime? lastUpdated,
|
||||
}) =>
|
||||
PantryItem(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
quantityOnHand: quantityOnHand ?? this.quantityOnHand,
|
||||
unit: unit ?? this.unit,
|
||||
category: category ?? this.category,
|
||||
expirationDate: expirationDate ?? this.expirationDate,
|
||||
lastUpdated: lastUpdated ?? this.lastUpdated,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'PantryItem($id, "$name", $quantityOnHand $unit, ${category.persistenceKey})';
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
/// One row of the shopping checklist.
|
||||
///
|
||||
/// Pure Dart (no framework imports). Rows created by the delta engine carry
|
||||
/// [isAutoGenerated] = `true` plus the contributing [sourceRecipeTitles];
|
||||
/// manual rows are user-owned and are never rewritten by meal-plan syncs.
|
||||
class ShoppingListItem {
|
||||
const ShoppingListItem({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.requiredQuantity,
|
||||
required this.unit,
|
||||
this.isPurchased = false,
|
||||
this.isAutoGenerated = false,
|
||||
this.sourceRecipeTitles = const [],
|
||||
});
|
||||
|
||||
/// UUID primary key assigned when the row is first created.
|
||||
final String id;
|
||||
|
||||
/// Ingredient name as displayed; matching against the pantry normalizes
|
||||
/// both sides with `canonicalIngredientName`, so casing is cosmetic.
|
||||
final String name;
|
||||
|
||||
/// Amount still needed, expressed in [unit].
|
||||
final double requiredQuantity;
|
||||
|
||||
/// Canonical unit key (e.g. `g`, `count`).
|
||||
final String unit;
|
||||
|
||||
/// Whether the item has been checked off the list.
|
||||
final bool isPurchased;
|
||||
|
||||
/// `true` for rows owned by the delta engine (recomputed on every sync).
|
||||
final bool isAutoGenerated;
|
||||
|
||||
/// Recipe titles that contributed to the deficit (auto rows only).
|
||||
final List<String> sourceRecipeTitles;
|
||||
|
||||
ShoppingListItem copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
double? requiredQuantity,
|
||||
String? unit,
|
||||
bool? isPurchased,
|
||||
bool? isAutoGenerated,
|
||||
List<String>? sourceRecipeTitles,
|
||||
}) =>
|
||||
ShoppingListItem(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
requiredQuantity: requiredQuantity ?? this.requiredQuantity,
|
||||
unit: unit ?? this.unit,
|
||||
isPurchased: isPurchased ?? this.isPurchased,
|
||||
isAutoGenerated: isAutoGenerated ?? this.isAutoGenerated,
|
||||
sourceRecipeTitles: sourceRecipeTitles ?? this.sourceRecipeTitles,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ShoppingListItem($id, "$name", $requiredQuantity $unit, '
|
||||
'purchased: $isPurchased, auto: $isAutoGenerated)';
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import '../entities/pantry_item.dart';
|
||||
import '../entities/shopping_list_item.dart';
|
||||
|
||||
/// Persistence contract for the pantry bounded context: on-hand inventory
|
||||
/// plus the shopping checklist.
|
||||
///
|
||||
/// The presentation and domain layers depend only on this interface; the
|
||||
/// drift-backed implementation lives in `data/repositories/` so widgets,
|
||||
/// controllers, and use cases remain testable with in-memory fakes.
|
||||
abstract class IPantryRepository {
|
||||
/// Emits pantry stock (alphabetical) whenever it changes.
|
||||
Stream<List<PantryItem>> watchItems();
|
||||
|
||||
/// One-shot snapshot of pantry stock (alphabetical).
|
||||
Future<List<PantryItem>> getItems();
|
||||
|
||||
/// The inventory row with [id], or `null` when absent.
|
||||
Future<PantryItem?> getItem(String id);
|
||||
|
||||
/// Inserts or updates [item] (keyed by `PantryItem.id`).
|
||||
Future<PantryItem> saveItem(PantryItem item);
|
||||
|
||||
/// Removes the inventory row with [id], if present.
|
||||
Future<void> deleteItem(String id);
|
||||
|
||||
/// Emits the shopping checklist (unchecked rows first) whenever it changes.
|
||||
Stream<List<ShoppingListItem>> watchShoppingList();
|
||||
|
||||
/// One-shot snapshot of the shopping checklist (unchecked first).
|
||||
Future<List<ShoppingListItem>> getShoppingList();
|
||||
|
||||
/// The checklist row with [id], or `null` when absent.
|
||||
Future<ShoppingListItem?> getShoppingListItem(String id);
|
||||
|
||||
/// Inserts or updates [item] (keyed by `ShoppingListItem.id`).
|
||||
Future<ShoppingListItem> saveShoppingListItem(ShoppingListItem item);
|
||||
|
||||
/// Removes the checklist row with [id], if present.
|
||||
Future<void> deleteShoppingListItem(String id);
|
||||
}
|
||||
|
|
@ -0,0 +1,293 @@
|
|||
import '../../../../core/utils/uuid.dart';
|
||||
import '../../../meal_planner/domain/entities/meal_plan_slot.dart';
|
||||
import '../../../meal_planner/domain/entities/recipe.dart';
|
||||
import '../entities/pantry_item.dart';
|
||||
import '../entities/shopping_list_item.dart';
|
||||
import '../repositories/pantry_repository.dart';
|
||||
import '../value_objects/ingredient_name.dart';
|
||||
import '../value_objects/measurement_units.dart';
|
||||
|
||||
/// Outcome of one delta-engine run, surfaced by the sync banner.
|
||||
class ShoppingDeltaResult {
|
||||
const ShoppingDeltaResult({
|
||||
required this.generatedItems,
|
||||
required this.updatedItems,
|
||||
required this.removedItems,
|
||||
required this.preservedManualItems,
|
||||
required this.satisfiedNames,
|
||||
required this.coveredByManualNames,
|
||||
});
|
||||
|
||||
/// Fresh auto-generated rows for previously uncovered deficits.
|
||||
final List<ShoppingListItem> generatedItems;
|
||||
|
||||
/// Existing auto-generated rows refreshed with a new deficit.
|
||||
final List<ShoppingListItem> updatedItems;
|
||||
|
||||
/// Auto-generated rows removed because their requirement vanished
|
||||
/// (pantry now covers it, or a manual row owns the name).
|
||||
final List<ShoppingListItem> removedItems;
|
||||
|
||||
/// Manual rows observed during the sync — always left untouched.
|
||||
final List<ShoppingListItem> preservedManualItems;
|
||||
|
||||
/// Canonical ingredient names fully covered by current pantry stock.
|
||||
final List<String> satisfiedNames;
|
||||
|
||||
/// Canonical ingredient names skipped because a manual row exists.
|
||||
final List<String> coveredByManualNames;
|
||||
|
||||
int get addedCount => generatedItems.length;
|
||||
int get updatedCount => updatedItems.length;
|
||||
int get clearedCount => removedItems.length;
|
||||
|
||||
/// Human-readable summary for the shopping-list sync banner.
|
||||
String get summary {
|
||||
final parts = <String>[
|
||||
if (generatedItems.isNotEmpty) '$addedCount added',
|
||||
if (updatedItems.isNotEmpty) '$updatedCount updated',
|
||||
if (removedItems.isNotEmpty) '$clearedCount cleared',
|
||||
];
|
||||
if (parts.isEmpty) {
|
||||
return "Your pantry already covers this week's meal plan.";
|
||||
}
|
||||
return 'Sync complete: ${parts.join(', ')}.';
|
||||
}
|
||||
}
|
||||
|
||||
/// Accumulated requirement for one canonical ingredient name across the
|
||||
/// active meal-plan window.
|
||||
class _IngredientRequirement {
|
||||
UnitDimension? dimension;
|
||||
|
||||
/// Requirement summed in the dimension's base unit (g / ml / each).
|
||||
double baseTotal = 0;
|
||||
|
||||
/// Per-unit base totals used to pick the dominant display unit.
|
||||
final Map<String, double> baseByUnit = <String, double>{};
|
||||
|
||||
/// Deduplicated recipe titles contributing to this requirement.
|
||||
final List<String> sourceTitles = <String>[];
|
||||
}
|
||||
|
||||
/// Smart Pantry Delta Generator (architecture §7).
|
||||
///
|
||||
/// Aggregates the ingredient manifests of the active 7-day meal plan by
|
||||
/// canonical ingredient name, normalizes units onto their dimension's base
|
||||
/// scale, and applies
|
||||
///
|
||||
/// ```
|
||||
/// deficit = totalRequired − currentPantryQuantity
|
||||
/// ```
|
||||
///
|
||||
/// per ingredient. Deficits above zero become/refresh `isAutoGenerated`
|
||||
/// shopping rows (logging the contributing recipes), rows whose requirement
|
||||
/// disappeared are cleared, and **manual rows are never modified or removed**.
|
||||
class CalculateShoppingDelta {
|
||||
const CalculateShoppingDelta(this._repository);
|
||||
|
||||
/// Numerical floor for "has a deficit" comparisons in base units.
|
||||
static const double _epsilon = 1e-9;
|
||||
|
||||
/// Maximum persisted name/unit lengths (mirrors the drift column bounds).
|
||||
static const int _maxNameLength = 120;
|
||||
static const int _maxUnitLength = 16;
|
||||
|
||||
final IPantryRepository _repository;
|
||||
|
||||
/// Runs one sync pass over [slots] (the active 7-day window) resolved
|
||||
/// against their [recipes] ingredient manifests.
|
||||
Future<ShoppingDeltaResult> call({
|
||||
required List<MealPlanSlot> slots,
|
||||
required List<Recipe> recipes,
|
||||
}) async {
|
||||
final pantry = await _repository.getItems();
|
||||
final existing = await _repository.getShoppingList();
|
||||
|
||||
final requirements = _aggregateRequirements(slots, recipes);
|
||||
|
||||
// Index pantry stock by canonical name.
|
||||
final stockByName = <String, List<PantryItem>>{};
|
||||
for (final item in pantry) {
|
||||
stockByName
|
||||
.putIfAbsent(canonicalIngredientName(item.name), () => <PantryItem>[])
|
||||
.add(item);
|
||||
}
|
||||
|
||||
// Split the current list: manual rows own their name outright; auto rows
|
||||
// are engine-owned and may be rewritten or removed.
|
||||
final manualByName = <String, ShoppingListItem>{};
|
||||
final autoByName = <String, ShoppingListItem>{};
|
||||
for (final item in existing) {
|
||||
final key = canonicalIngredientName(item.name);
|
||||
if (item.isAutoGenerated) {
|
||||
autoByName.putIfAbsent(key, () => item);
|
||||
} else {
|
||||
manualByName.putIfAbsent(key, () => item);
|
||||
}
|
||||
}
|
||||
|
||||
final generated = <ShoppingListItem>[];
|
||||
final updated = <ShoppingListItem>[];
|
||||
final toSave = <ShoppingListItem>[];
|
||||
final satisfied = <String>[];
|
||||
final coveredByManual = <String>[];
|
||||
|
||||
for (final entry in requirements.entries) {
|
||||
final name = entry.key;
|
||||
final requirement = entry.value;
|
||||
final displayUnit = _preferredDisplayUnit(requirement);
|
||||
|
||||
var availableBase = 0.0;
|
||||
final stocked = stockByName[name];
|
||||
if (stocked != null) {
|
||||
for (final item in stocked) {
|
||||
final stockUnit = canonicalUnit(item.unit);
|
||||
// Stock measured in an incompatible dimension cannot offset the
|
||||
// requirement (grams do not cancel against item counts).
|
||||
if (unitDimension(stockUnit) != requirement.dimension) continue;
|
||||
availableBase += unitsToBase(item.quantityOnHand, stockUnit);
|
||||
}
|
||||
}
|
||||
|
||||
final deficitBase = requirement.baseTotal - availableBase;
|
||||
final deficit = roundTo2(unitsFromBase(deficitBase, displayUnit));
|
||||
if (deficitBase <= _epsilon || deficit <= 0) {
|
||||
satisfied.add(name);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Manual rows win their name: never rewritten, never duplicated.
|
||||
if (manualByName.containsKey(name)) {
|
||||
coveredByManual.add(name);
|
||||
continue;
|
||||
}
|
||||
|
||||
final existingAuto = autoByName[name];
|
||||
// Keep the checked-off state only while the requirement is unchanged;
|
||||
// a changed deficit means there is (new) shopping to do.
|
||||
final keepChecked = existingAuto != null &&
|
||||
existingAuto.isPurchased &&
|
||||
existingAuto.requiredQuantity == deficit &&
|
||||
existingAuto.unit == displayUnit;
|
||||
final item = ShoppingListItem(
|
||||
id: existingAuto?.id ?? generateUuid(),
|
||||
name: _clampName(name),
|
||||
requiredQuantity: deficit,
|
||||
unit: _clampUnit(displayUnit),
|
||||
isPurchased: keepChecked,
|
||||
isAutoGenerated: true,
|
||||
sourceRecipeTitles: List<String>.unmodifiable(requirement.sourceTitles),
|
||||
);
|
||||
toSave.add(item);
|
||||
if (existingAuto == null) {
|
||||
generated.add(item);
|
||||
} else {
|
||||
updated.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Engine-owned rows whose requirement vanished (satisfied by stock or
|
||||
// superseded by a manual row) are removed; manual rows never are.
|
||||
final survivingIds = {for (final item in toSave) item.id};
|
||||
final toRemove = <ShoppingListItem>[
|
||||
for (final item in existing)
|
||||
if (item.isAutoGenerated && !survivingIds.contains(item.id)) item,
|
||||
];
|
||||
|
||||
for (final item in toRemove) {
|
||||
await _repository.deleteShoppingListItem(item.id);
|
||||
}
|
||||
for (final item in toSave) {
|
||||
await _repository.saveShoppingListItem(item);
|
||||
}
|
||||
|
||||
return ShoppingDeltaResult(
|
||||
generatedItems: generated,
|
||||
updatedItems: updated,
|
||||
removedItems: toRemove,
|
||||
preservedManualItems: [
|
||||
for (final item in existing)
|
||||
if (!item.isAutoGenerated) item,
|
||||
],
|
||||
satisfiedNames: satisfied,
|
||||
coveredByManualNames: coveredByManual,
|
||||
);
|
||||
}
|
||||
|
||||
/// Sums every resolvable recipe ingredient across the window, keyed by
|
||||
/// canonical name. Slots are ordered by date (then id) so aggregations —
|
||||
/// and the dominant display unit they imply — are deterministic.
|
||||
Map<String, _IngredientRequirement> _aggregateRequirements(
|
||||
List<MealPlanSlot> slots,
|
||||
List<Recipe> recipes,
|
||||
) {
|
||||
final recipesById = <String, Recipe>{
|
||||
for (final recipe in recipes) recipe.id: recipe,
|
||||
};
|
||||
final orderedSlots = List<MealPlanSlot>.of(slots)
|
||||
..sort((a, b) {
|
||||
final byDate = a.date.compareTo(b.date);
|
||||
return byDate != 0 ? byDate : a.id.compareTo(b.id);
|
||||
});
|
||||
|
||||
final requirements = <String, _IngredientRequirement>{};
|
||||
for (final slot in orderedSlots) {
|
||||
final recipeId = slot.recipeId;
|
||||
if (recipeId == null) continue;
|
||||
final recipe = recipesById[recipeId];
|
||||
if (recipe == null) continue;
|
||||
|
||||
for (final ingredient in recipe.ingredients) {
|
||||
final rawName = ingredient.parsedItemName.isNotEmpty
|
||||
? ingredient.parsedItemName
|
||||
: ingredient.rawText;
|
||||
final name = canonicalIngredientName(rawName);
|
||||
if (name.isEmpty) continue;
|
||||
final quantity = ingredient.quantity;
|
||||
// Unquantified lines ("salt to taste") cannot produce a delta.
|
||||
if (quantity == null || quantity <= 0) continue;
|
||||
|
||||
final unit = canonicalUnit(ingredient.unit);
|
||||
final dimension = unitDimension(unit);
|
||||
final requirement =
|
||||
requirements.putIfAbsent(name, _IngredientRequirement.new);
|
||||
requirement.dimension ??= dimension;
|
||||
// Guard against pathological same-name/different-dimension mixes.
|
||||
if (requirement.dimension != dimension) continue;
|
||||
|
||||
final base = unitsToBase(quantity, unit);
|
||||
requirement.baseTotal += base;
|
||||
requirement.baseByUnit[unit] =
|
||||
(requirement.baseByUnit[unit] ?? 0) + base;
|
||||
|
||||
final title = recipe.title.trim();
|
||||
if (title.isNotEmpty && !requirement.sourceTitles.contains(title)) {
|
||||
requirement.sourceTitles.add(title);
|
||||
}
|
||||
}
|
||||
}
|
||||
return requirements;
|
||||
}
|
||||
|
||||
/// The unit that contributed the most base quantity (ties resolve to the
|
||||
/// first-seen unit), so a plan written mostly in `lb` reports in `lb`.
|
||||
String _preferredDisplayUnit(_IngredientRequirement requirement) {
|
||||
var bestUnit = 'count';
|
||||
var bestTotal = -1.0;
|
||||
for (final entry in requirement.baseByUnit.entries) {
|
||||
if (entry.value > bestTotal) {
|
||||
bestTotal = entry.value;
|
||||
bestUnit = entry.key;
|
||||
}
|
||||
}
|
||||
return bestUnit;
|
||||
}
|
||||
|
||||
String _clampName(String name) =>
|
||||
name.length <= _maxNameLength ? name : name.substring(0, _maxNameLength);
|
||||
|
||||
String _clampUnit(String unit) =>
|
||||
unit.length <= _maxUnitLength ? unit : 'count';
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/// Shared English-singularization used to fold plural ingredient and unit
|
||||
/// strings onto one canonical token (`eggs` → `egg`, `lbs` → `lb`,
|
||||
/// `chicken breasts` → `chicken breast`).
|
||||
///
|
||||
/// Rules are intentionally conservative and applied identically to both sides
|
||||
/// of a comparison (recipe line vs. pantry row), so even imperfect results
|
||||
/// still match as long as they are consistent.
|
||||
String singularizeWord(String word) {
|
||||
if (word.length <= 2) return word;
|
||||
// berries → berry.
|
||||
if (word.endsWith('ies') && word.length > 4) {
|
||||
return '${word.substring(0, word.length - 3)}y';
|
||||
}
|
||||
// class / glass — never strip a double s.
|
||||
if (word.endsWith('ss')) return word;
|
||||
// bunches → bunch, boxes → box, buses → bus, tomatoes → tomato.
|
||||
if (word.endsWith('ches') ||
|
||||
word.endsWith('shes') ||
|
||||
word.endsWith('xes') ||
|
||||
word.endsWith('zes') ||
|
||||
word.endsWith('ses') ||
|
||||
word.endsWith('oes')) {
|
||||
return word.substring(0, word.length - 2);
|
||||
}
|
||||
// status / asparagus keep their s.
|
||||
if (word.endsWith('us') || word.endsWith('is')) return word;
|
||||
// eggs → egg.
|
||||
if (word.endsWith('s')) return word.substring(0, word.length - 1);
|
||||
return word;
|
||||
}
|
||||
|
||||
/// Canonical, lowercase ingredient key used for fuzzy pantry ↔ recipe
|
||||
/// matching (e.g. `Chicken Breast (boneless)` → `chicken breast`).
|
||||
///
|
||||
/// The pipeline: trim/lowercase → drop parenthetical qualifiers → collapse
|
||||
/// punctuation to spaces → singularize each token.
|
||||
String canonicalIngredientName(String raw) {
|
||||
var text = raw.trim().toLowerCase();
|
||||
if (text.isEmpty) return '';
|
||||
// Drop parenthetical qualifiers: "chicken breast (boneless)" → "chicken breast".
|
||||
text = text.replaceAll(RegExp(r'\([^)]*\)'), ' ');
|
||||
// Collapse punctuation so "chicken-breast" and "chicken, breast" still
|
||||
// match the plain "chicken breast" pantry key.
|
||||
text = text.replaceAll(RegExp(r'[^a-z0-9]+'), ' ');
|
||||
text = text.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
if (text.isEmpty) return '';
|
||||
return text.split(' ').map(singularizeWord).join(' ');
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import 'ingredient_name.dart';
|
||||
|
||||
/// Physical dimension a unit belongs to. Quantities are only summed (and
|
||||
/// subtracted from pantry stock) within the same dimension — grams never
|
||||
/// cancel against millilitres or item counts.
|
||||
enum UnitDimension { mass, volume, count }
|
||||
|
||||
/// Units expressed in grams at the base scale.
|
||||
const Set<String> _massUnits = {'mg', 'g', 'kg', 'oz', 'lb'};
|
||||
|
||||
/// Units expressed in millilitres at the base scale.
|
||||
const Set<String> _volumeUnits = {'ml', 'l', 'tsp', 'tbsp', 'cup', 'floz'};
|
||||
|
||||
/// Synonyms applied after lowercasing + singularizing a raw unit token
|
||||
/// (`grams` → `gram` → `g`, `lbs` → `lb`, `pieces` → `count`).
|
||||
const Map<String, String> _unitSynonyms = {
|
||||
'milligram': 'mg',
|
||||
'gram': 'g',
|
||||
'kilogram': 'kg',
|
||||
'ounce': 'oz',
|
||||
'pound': 'lb',
|
||||
'milliliter': 'ml',
|
||||
'liter': 'l',
|
||||
'teaspoon': 'tsp',
|
||||
'tablespoon': 'tbsp',
|
||||
'fluidounce': 'floz',
|
||||
'piece': 'count',
|
||||
'unit': 'count',
|
||||
'each': 'count',
|
||||
'item': 'count',
|
||||
};
|
||||
|
||||
/// Canonical unit spellings offered by the pantry/shopping dialogs.
|
||||
const List<String> pantryUnitOptions = [
|
||||
'count',
|
||||
'g',
|
||||
'kg',
|
||||
'oz',
|
||||
'lb',
|
||||
'ml',
|
||||
'l',
|
||||
'tsp',
|
||||
'tbsp',
|
||||
'cup',
|
||||
];
|
||||
|
||||
/// Canonicalizes a raw unit token (recipe parser output, dialog selection, or
|
||||
/// persisted value). `null`/empty defaults to the generic `count`.
|
||||
String canonicalUnit(String? raw) {
|
||||
if (raw == null) return 'count';
|
||||
final trimmed = raw.trim().toLowerCase();
|
||||
if (trimmed.isEmpty) return 'count';
|
||||
final singular = singularizeWord(trimmed);
|
||||
return _unitSynonyms[singular] ?? singular;
|
||||
}
|
||||
|
||||
/// Dimension [canonical] belongs to (see [UnitDimension]).
|
||||
UnitDimension unitDimension(String canonical) {
|
||||
if (_massUnits.contains(canonical)) return UnitDimension.mass;
|
||||
if (_volumeUnits.contains(canonical)) return UnitDimension.volume;
|
||||
return UnitDimension.count;
|
||||
}
|
||||
|
||||
/// Multiplicative factor that converts one [canonical] unit into the base
|
||||
/// unit of its dimension (g / ml / each).
|
||||
double _factorToBase(String canonical) {
|
||||
switch (canonical) {
|
||||
case 'mg':
|
||||
return 0.001;
|
||||
case 'kg':
|
||||
return 1000;
|
||||
case 'oz':
|
||||
return 28.349523125;
|
||||
case 'lb':
|
||||
return 453.59237;
|
||||
case 'l':
|
||||
return 1000;
|
||||
case 'floz':
|
||||
return 29.5735295625;
|
||||
case 'tsp':
|
||||
return 4.92892159375;
|
||||
case 'tbsp':
|
||||
return 14.78676478125;
|
||||
case 'cup':
|
||||
return 236.5882365;
|
||||
case 'dozen':
|
||||
return 12;
|
||||
default:
|
||||
// `g`, `ml`, `count` and custom count-style units are their own base.
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts [quantity] of [canonical] into the base unit of its dimension
|
||||
/// (grams / millilitres / each), enabling cross-unit aggregation such as
|
||||
/// `0.5 lb + 226.8 g`.
|
||||
double unitsToBase(double quantity, String canonical) =>
|
||||
quantity * _factorToBase(canonical);
|
||||
|
||||
/// Inverse of [unitsToBase]: expresses [baseQuantity] in [canonical] units.
|
||||
double unitsFromBase(double baseQuantity, String canonical) {
|
||||
final factor = _factorToBase(canonical);
|
||||
return factor == 0 ? baseQuantity : baseQuantity / factor;
|
||||
}
|
||||
|
||||
/// Rounds derived quantities to 2 decimals for stable storage and display.
|
||||
double roundTo2(double value) => (value * 100).roundToDouble() / 100;
|
||||
|
||||
/// Compact display formatting for quantities: up to 2 decimals with trailing
|
||||
/// zeros trimmed (e.g. `500`, `0.75`, `1.5`).
|
||||
String formatQuantityForDisplay(double value) {
|
||||
final rounded = roundTo2(value);
|
||||
if (rounded == rounded.roundToDouble()) return rounded.round().toString();
|
||||
return rounded.toString();
|
||||
}
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
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,
|
||||
);
|
||||
|
|
@ -0,0 +1,299 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/utils/date_time_utils.dart';
|
||||
import '../../../../core/utils/uuid.dart';
|
||||
import '../../../meal_planner/presentation/controllers/meal_plan_calendar_controller.dart';
|
||||
import '../../../meal_planner/presentation/controllers/recipe_import_controller.dart';
|
||||
import '../../domain/entities/pantry_category.dart';
|
||||
import '../../domain/entities/pantry_item.dart';
|
||||
import '../../domain/entities/shopping_list_item.dart';
|
||||
import '../../domain/usecases/calculate_shopping_delta.dart';
|
||||
import '../../domain/value_objects/ingredient_name.dart';
|
||||
import '../../domain/value_objects/measurement_units.dart';
|
||||
import 'pantry_inventory_controller.dart';
|
||||
|
||||
/// Application-wide delta-engine use case, wired to the pantry repository.
|
||||
///
|
||||
/// Overridable in tests with a fake repository.
|
||||
final calculateShoppingDeltaProvider = Provider<CalculateShoppingDelta>(
|
||||
(ref) => CalculateShoppingDelta(ref.watch(pantryRepositoryProvider)),
|
||||
);
|
||||
|
||||
/// Checklist view filter.
|
||||
enum ShoppingListFilter { all, toBuy, checked }
|
||||
|
||||
/// Immutable snapshot of the shopping-checklist state.
|
||||
class ShoppingListState {
|
||||
const ShoppingListState({
|
||||
this.items = const [],
|
||||
this.filter = ShoppingListFilter.all,
|
||||
this.isSyncing = false,
|
||||
this.hasLoaded = false,
|
||||
this.statusMessage,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
/// Sentinel allowing `copyWith` to explicitly reset a field to `null`.
|
||||
static const Object _keep = Object();
|
||||
|
||||
/// Checklist rows (unchecked first, kept live by the watch stream).
|
||||
final List<ShoppingListItem> items;
|
||||
|
||||
/// Active checklist filter.
|
||||
final ShoppingListFilter filter;
|
||||
|
||||
/// `true` while the delta engine runs against the meal plan.
|
||||
final bool isSyncing;
|
||||
|
||||
/// `true` once the first watch emission arrived.
|
||||
final bool hasLoaded;
|
||||
|
||||
/// Last successful sync summary (e.g. `Sync complete: 2 added, 1 cleared.`).
|
||||
final String? statusMessage;
|
||||
|
||||
/// Last sync/persist failure message.
|
||||
final String? errorMessage;
|
||||
|
||||
/// Rows for the active filter.
|
||||
List<ShoppingListItem> get visibleItems => switch (filter) {
|
||||
ShoppingListFilter.all => items,
|
||||
ShoppingListFilter.toBuy => [
|
||||
for (final item in items)
|
||||
if (!item.isPurchased) item,
|
||||
],
|
||||
ShoppingListFilter.checked => [
|
||||
for (final item in items)
|
||||
if (item.isPurchased) item,
|
||||
],
|
||||
};
|
||||
|
||||
/// Rows still to buy (drives the tab badge).
|
||||
int get remainingCount =>
|
||||
items.where((item) => !item.isPurchased).length;
|
||||
|
||||
ShoppingListState copyWith({
|
||||
List<ShoppingListItem>? items,
|
||||
ShoppingListFilter? filter,
|
||||
bool? isSyncing,
|
||||
bool? hasLoaded,
|
||||
Object? statusMessage = _keep,
|
||||
Object? errorMessage = _keep,
|
||||
}) =>
|
||||
ShoppingListState(
|
||||
items: items ?? this.items,
|
||||
filter: filter ?? this.filter,
|
||||
isSyncing: isSyncing ?? this.isSyncing,
|
||||
hasLoaded: hasLoaded ?? this.hasLoaded,
|
||||
statusMessage: identical(statusMessage, _keep)
|
||||
? this.statusMessage
|
||||
: statusMessage as String?,
|
||||
errorMessage: identical(errorMessage, _keep)
|
||||
? this.errorMessage
|
||||
: errorMessage as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Orchestrates the shopping checklist: manual additions, check-off with an
|
||||
/// optional pantry-stock transfer, filtering, and the meal-plan delta sync.
|
||||
class ShoppingListController extends Notifier<ShoppingListState> {
|
||||
@override
|
||||
ShoppingListState build() {
|
||||
final subscription = ref
|
||||
.read(pantryRepositoryProvider)
|
||||
.watchShoppingList()
|
||||
.listen(
|
||||
(items) => state = state.copyWith(items: items, hasLoaded: true),
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
debugPrintStack(
|
||||
stackTrace: stackTrace,
|
||||
label: 'shopping list watch failed',
|
||||
);
|
||||
state = state.copyWith(errorMessage: 'Could not load the list.');
|
||||
},
|
||||
);
|
||||
ref.onDispose(subscription.cancel);
|
||||
return const ShoppingListState();
|
||||
}
|
||||
|
||||
/// Adds a manual checklist row (never touched by delta syncs).
|
||||
Future<bool> addItem({
|
||||
required String name,
|
||||
required double quantity,
|
||||
required String unit,
|
||||
}) async {
|
||||
final trimmed = name.trim();
|
||||
if (trimmed.isEmpty || quantity <= 0) {
|
||||
state = state.copyWith(
|
||||
errorMessage: 'Enter a name and a quantity above zero.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await ref.read(pantryRepositoryProvider).saveShoppingListItem(
|
||||
ShoppingListItem(
|
||||
id: generateUuid(),
|
||||
name: trimmed,
|
||||
requiredQuantity: quantity,
|
||||
unit: unit,
|
||||
),
|
||||
);
|
||||
state = state.copyWith(errorMessage: null, statusMessage: null);
|
||||
return true;
|
||||
} catch (error, stackTrace) {
|
||||
debugPrintStack(
|
||||
stackTrace: stackTrace,
|
||||
label: 'shopping item save failed',
|
||||
);
|
||||
state = state.copyWith(errorMessage: 'Could not add the item.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Flips the checked-off state of [item].
|
||||
Future<void> togglePurchased(ShoppingListItem item) =>
|
||||
_save(item.copyWith(isPurchased: !item.isPurchased));
|
||||
|
||||
/// Marks [item] checked. With [addToPantry] the purchased quantity is first
|
||||
/// merged into pantry stock — converting units when both sides share a
|
||||
/// dimension, creating a new (aisle-inferred) row otherwise.
|
||||
Future<bool> completeItem(
|
||||
ShoppingListItem item, {
|
||||
required bool addToPantry,
|
||||
}) async {
|
||||
try {
|
||||
if (addToPantry) await _transferToPantry(item);
|
||||
await ref.read(pantryRepositoryProvider).saveShoppingListItem(
|
||||
item.copyWith(isPurchased: true),
|
||||
);
|
||||
return true;
|
||||
} catch (error, stackTrace) {
|
||||
debugPrintStack(
|
||||
stackTrace: stackTrace,
|
||||
label: 'shopping check-off failed',
|
||||
);
|
||||
state = state.copyWith(
|
||||
errorMessage: 'Could not update the shopping list.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save(ShoppingListItem item) async {
|
||||
try {
|
||||
await ref.read(pantryRepositoryProvider).saveShoppingListItem(item);
|
||||
} catch (error, stackTrace) {
|
||||
debugPrintStack(
|
||||
stackTrace: stackTrace,
|
||||
label: 'shopping item save failed',
|
||||
);
|
||||
state = state.copyWith(errorMessage: 'Could not update the list.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _transferToPantry(ShoppingListItem item) async {
|
||||
final pantry = ref.read(pantryRepositoryProvider);
|
||||
final key = canonicalIngredientName(item.name);
|
||||
final stock = await pantry.getItems();
|
||||
|
||||
PantryItem? match;
|
||||
for (final candidate in stock) {
|
||||
if (canonicalIngredientName(candidate.name) == key) {
|
||||
match = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final itemUnit = canonicalUnit(item.unit);
|
||||
if (match != null &&
|
||||
unitDimension(canonicalUnit(match.unit)) == unitDimension(itemUnit)) {
|
||||
// Merge into the existing row, converting into its unit when needed.
|
||||
final converted = unitsFromBase(
|
||||
unitsToBase(item.requiredQuantity, itemUnit),
|
||||
canonicalUnit(match.unit),
|
||||
);
|
||||
await pantry.saveItem(
|
||||
match.copyWith(
|
||||
quantityOnHand: match.quantityOnHand + converted,
|
||||
lastUpdated: DateTime.now(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await pantry.saveItem(
|
||||
PantryItem(
|
||||
id: generateUuid(),
|
||||
// Canonical lowercase key so future delta syncs match this row.
|
||||
name: key.isEmpty ? item.name.trim() : key,
|
||||
quantityOnHand: item.requiredQuantity,
|
||||
unit: itemUnit,
|
||||
category: PantryCategory.infer(item.name),
|
||||
lastUpdated: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Removes the checklist row with [id] (swipe-to-delete).
|
||||
Future<void> removeItem(String id) async {
|
||||
try {
|
||||
await ref.read(pantryRepositoryProvider).deleteShoppingListItem(id);
|
||||
} catch (error, stackTrace) {
|
||||
debugPrintStack(
|
||||
stackTrace: stackTrace,
|
||||
label: 'shopping item delete failed',
|
||||
);
|
||||
state = state.copyWith(errorMessage: 'Could not remove the item.');
|
||||
}
|
||||
}
|
||||
|
||||
/// Switches the active checklist filter (All / To Buy / Checked).
|
||||
void setFilter(ShoppingListFilter filter) =>
|
||||
state = state.copyWith(filter: filter);
|
||||
|
||||
/// Clears the last status/error messages.
|
||||
void clearMessages() =>
|
||||
state = state.copyWith(statusMessage: null, errorMessage: null);
|
||||
|
||||
/// Runs the delta engine against the active Monday–Sunday meal-plan window
|
||||
/// (or [windowStart]) and returns the human-readable summary — or `null`
|
||||
/// when the sync failed (see `ShoppingListState.errorMessage`).
|
||||
Future<String?> syncWithMealPlan({DateTime? windowStart}) async {
|
||||
if (state.isSyncing) return null;
|
||||
state = state.copyWith(
|
||||
isSyncing: true,
|
||||
statusMessage: null,
|
||||
errorMessage: null,
|
||||
);
|
||||
try {
|
||||
final start = windowStart ?? startOfWeek(DateTime.now());
|
||||
final slots = await ref.read(mealPlanRepositoryProvider).getSlots(
|
||||
from: start,
|
||||
to: start.add(const Duration(days: 7)),
|
||||
);
|
||||
final recipes = await ref.read(recipeRepositoryProvider).getRecipes();
|
||||
final result = await ref.read(calculateShoppingDeltaProvider)(
|
||||
slots: slots,
|
||||
recipes: recipes,
|
||||
);
|
||||
state = state.copyWith(
|
||||
isSyncing: false,
|
||||
statusMessage: result.summary,
|
||||
);
|
||||
return result.summary;
|
||||
} catch (error, stackTrace) {
|
||||
debugPrintStack(stackTrace: stackTrace, label: 'pantry delta sync failed');
|
||||
state = state.copyWith(
|
||||
isSyncing: false,
|
||||
errorMessage: 'Could not sync with the meal plan.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exposes the [ShoppingListController] to the widget tree.
|
||||
final shoppingListControllerProvider =
|
||||
NotifierProvider<ShoppingListController, ShoppingListState>(
|
||||
ShoppingListController.new,
|
||||
);
|
||||
|
|
@ -1,51 +1,683 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class PantryScreen extends StatelessWidget {
|
||||
import '../../../../core/utils/date_time_utils.dart';
|
||||
import '../../domain/entities/pantry_category.dart';
|
||||
import '../../domain/entities/pantry_item.dart';
|
||||
import '../../domain/entities/shopping_list_item.dart';
|
||||
import '../../domain/value_objects/measurement_units.dart';
|
||||
import '../controllers/pantry_inventory_controller.dart';
|
||||
import '../controllers/shopping_list_controller.dart';
|
||||
import '../widgets/add_pantry_item_dialog.dart';
|
||||
import '../widgets/add_shopping_item_dialog.dart';
|
||||
import '../widgets/add_to_pantry_prompt.dart';
|
||||
|
||||
/// Tabbed pantry hub: on-hand inventory with quick stock counters on one
|
||||
/// side, the meal-plan-synced shopping checklist on the other.
|
||||
class PantryScreen extends ConsumerStatefulWidget {
|
||||
const PantryScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<PantryScreen> createState() => _PantryScreenState();
|
||||
}
|
||||
|
||||
class _PantryScreenState extends ConsumerState<PantryScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabController =
|
||||
TabController(length: 2, vsync: this);
|
||||
|
||||
/// Mirrors the controller's index so the FAB rebuilds exactly when the
|
||||
/// active tab changes (not on every animation tick).
|
||||
int _tabIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController.addListener(_handleTabChange);
|
||||
}
|
||||
|
||||
void _handleTabChange() {
|
||||
if (_tabIndex == _tabController.index) return;
|
||||
setState(() => _tabIndex = _tabController.index);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shoppingState = ref.watch(shoppingListControllerProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Pantry & Shopping Delta')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Inventory',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
appBar: AppBar(
|
||||
title: const Text('Pantry & Shopping Delta'),
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
tabs: [
|
||||
const Tab(
|
||||
icon: Icon(Icons.kitchen_outlined),
|
||||
text: 'Inventory',
|
||||
),
|
||||
Tab(
|
||||
icon: Badge(
|
||||
isLabelVisible: shoppingState.remainingCount > 0,
|
||||
label: Text('${shoppingState.remainingCount}'),
|
||||
child: const Icon(Icons.shopping_basket_outlined),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add Item'),
|
||||
text: 'Shopping List',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildInventoryTab(),
|
||||
_buildShoppingTab(),
|
||||
],
|
||||
),
|
||||
floatingActionButton: _tabIndex == 0
|
||||
? FloatingActionButton.extended(
|
||||
onPressed: _showAddPantryItemDialog,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add Pantry Item'),
|
||||
)
|
||||
: FloatingActionButton.extended(
|
||||
onPressed: _showAddShoppingItemDialog,
|
||||
icon: const Icon(Icons.add_shopping_cart),
|
||||
label: const Text('Add Item'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showAddPantryItemDialog() async {
|
||||
final saved = await showAddPantryItemDialog(context);
|
||||
if (saved != true || !mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Added to your pantry.')),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showAddShoppingItemDialog() async {
|
||||
final saved = await showAddShoppingItemDialog(context);
|
||||
if (saved != true || !mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Added to your shopping list.')),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inventory tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Widget _buildInventoryTab() {
|
||||
final state = ref.watch(pantryInventoryControllerProvider);
|
||||
|
||||
if (!state.hasLoaded && state.items.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: SegmentedButton<PantrySortMode>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: PantrySortMode.category,
|
||||
icon: Icon(Icons.category_outlined),
|
||||
label: Text('Aisles'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: PantrySortMode.expiration,
|
||||
icon: Icon(Icons.schedule),
|
||||
label: Text('Expiration'),
|
||||
),
|
||||
],
|
||||
selected: {state.sortMode},
|
||||
onSelectionChanged: (selection) => ref
|
||||
.read(pantryInventoryControllerProvider.notifier)
|
||||
.setSortMode(selection.first),
|
||||
),
|
||||
),
|
||||
if (state.errorMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Text(
|
||||
state.errorMessage!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: state.items.isEmpty
|
||||
? _buildEmptyInventory()
|
||||
: ListView(
|
||||
padding: const EdgeInsets.only(bottom: 88),
|
||||
children: state.sortMode == PantrySortMode.category
|
||||
? _buildCategorySections(state)
|
||||
: [
|
||||
for (final item in state.byExpiration)
|
||||
_buildInventoryTile(item),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Aisle header + tiles for every non-empty category.
|
||||
List<Widget> _buildCategorySections(PantryInventoryState state) {
|
||||
final sections = <Widget>[];
|
||||
for (final entry in state.groupedByCategory.entries) {
|
||||
sections.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_categoryIcon(entry.key),
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${entry.key.displayName} · ${entry.value.length}',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const ListTile(
|
||||
leading: Icon(Icons.check_box_outline_blank),
|
||||
title: Text('Boneless Chicken Breast'),
|
||||
subtitle: Text('In stock: 300g'),
|
||||
),
|
||||
);
|
||||
for (final item in entry.value) {
|
||||
sections.add(_buildInventoryTile(item));
|
||||
}
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
Widget _buildInventoryTile(PantryItem item) {
|
||||
final step = _quickStepFor(item.unit);
|
||||
final notifier = ref.read(pantryInventoryControllerProvider.notifier);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Dismissible(
|
||||
key: ValueKey('pantry-${item.id}'),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: ColoredBox(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
child: const Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(right: 20),
|
||||
child: Icon(Icons.delete_outline),
|
||||
),
|
||||
),
|
||||
const ListTile(
|
||||
leading: Icon(Icons.check_box_outline_blank),
|
||||
title: Text('Plain Greek Yogurt'),
|
||||
subtitle: Text('In stock: 500g'),
|
||||
),
|
||||
onDismissed: (_) => notifier.deleteItem(item.id),
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
const Text(
|
||||
'Shopping List (Auto-Generated Delta)',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
child: Icon(_categoryIcon(item.category), size: 16),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
if (item.expirationDate != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 2,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Expires ${formatMonthDay(item.expirationDate!)}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
_buildExpirationBadge(item),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: 'Remove one',
|
||||
icon: const Icon(Icons.remove_circle_outline),
|
||||
onPressed: () => notifier.adjustQuantity(item, -step),
|
||||
),
|
||||
Text(
|
||||
'${formatQuantityForDisplay(item.quantityOnHand)} ${item.unit}',
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: 'Add one',
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
onPressed: () => notifier.adjustQuantity(item, step),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const CheckboxListTile(
|
||||
value: false,
|
||||
onChanged: null,
|
||||
title: Text('Chicken Breast (Need +500g for Mon Plan)'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Compact warning chip for expired / soon-to-expire stock.
|
||||
Widget _buildExpirationBadge(PantryItem item) {
|
||||
final status = item.expirationStatus();
|
||||
if (status == ExpirationStatus.none || status == ExpirationStatus.fresh) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final Color background;
|
||||
final Color foreground;
|
||||
if (status == ExpirationStatus.expired) {
|
||||
background = scheme.errorContainer;
|
||||
foreground = scheme.onErrorContainer;
|
||||
} else {
|
||||
background = scheme.tertiaryContainer;
|
||||
foreground = scheme.onTertiaryContainer;
|
||||
}
|
||||
final days = item.daysUntilExpiration() ?? 0;
|
||||
final String label;
|
||||
if (status == ExpirationStatus.expired) {
|
||||
label = 'Expired';
|
||||
} else if (days <= 0) {
|
||||
label = 'Expires today';
|
||||
} else if (days == 1) {
|
||||
label = 'Expires tomorrow';
|
||||
} else {
|
||||
label = '$days days left';
|
||||
}
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: foreground,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyInventory() => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.kitchen_outlined,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Your pantry is empty',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Tap "Add Pantry Item" to stock up.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
/// Stock step for the quick +/- counters, chosen per unit family.
|
||||
double _quickStepFor(String rawUnit) {
|
||||
switch (canonicalUnit(rawUnit)) {
|
||||
case 'g':
|
||||
case 'ml':
|
||||
return 50;
|
||||
case 'kg':
|
||||
case 'l':
|
||||
return 0.5;
|
||||
case 'lb':
|
||||
return 0.25;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _categoryIcon(PantryCategory category) {
|
||||
switch (category) {
|
||||
case PantryCategory.produce:
|
||||
return Icons.local_florist_outlined;
|
||||
case PantryCategory.meatSeafood:
|
||||
return Icons.set_meal_outlined;
|
||||
case PantryCategory.dairy:
|
||||
return Icons.icecream_outlined;
|
||||
case PantryCategory.dryGoods:
|
||||
return Icons.grain_outlined;
|
||||
case PantryCategory.spices:
|
||||
return Icons.spa_outlined;
|
||||
case PantryCategory.other:
|
||||
return Icons.kitchen_outlined;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shopping list tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Widget _buildShoppingTab() {
|
||||
final state = ref.watch(shoppingListControllerProvider);
|
||||
final controller = ref.read(shoppingListControllerProvider.notifier);
|
||||
|
||||
if (!state.hasLoaded && state.items.isEmpty && !state.isSyncing) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_buildSyncBanner(state, controller),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||
child: SegmentedButton<ShoppingListFilter>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: ShoppingListFilter.all,
|
||||
label: Text('All'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ShoppingListFilter.toBuy,
|
||||
label: Text('To Buy'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ShoppingListFilter.checked,
|
||||
label: Text('Checked'),
|
||||
),
|
||||
],
|
||||
selected: {state.filter},
|
||||
onSelectionChanged: (selection) =>
|
||||
controller.setFilter(selection.first),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: state.visibleItems.isEmpty
|
||||
? _buildEmptyShoppingList(state)
|
||||
: ListView(
|
||||
padding: const EdgeInsets.only(bottom: 88),
|
||||
children: [
|
||||
for (final item in state.visibleItems)
|
||||
_buildShoppingTile(item, controller),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Prominent banner that triggers the meal-plan delta engine.
|
||||
Widget _buildSyncBanner(
|
||||
ShoppingListState state,
|
||||
ShoppingListController controller,
|
||||
) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.sync_alt, color: scheme.onSecondaryContainer),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Sync with Meal Plan',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleSmall
|
||||
?.copyWith(
|
||||
color: scheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"Compare this week's recipes against pantry stock.",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(color: scheme.onSecondaryContainer),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed:
|
||||
state.isSyncing ? null : () => _runSync(controller),
|
||||
icon: state.isSyncing
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.play_arrow),
|
||||
label: const Text('Run Sync'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (state.statusMessage != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
state.statusMessage!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (state.errorMessage != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
state.errorMessage!,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(color: scheme.error),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _runSync(ShoppingListController controller) async {
|
||||
final summary = await controller.syncWithMealPlan();
|
||||
if (summary == null || !mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(summary)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildShoppingTile(
|
||||
ShoppingListItem item,
|
||||
ShoppingListController controller,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Dismissible(
|
||||
key: ValueKey('shopping-${item.id}'),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: ColoredBox(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
child: const Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(right: 20),
|
||||
child: Icon(Icons.delete_outline),
|
||||
),
|
||||
),
|
||||
),
|
||||
onDismissed: (_) => controller.removeItem(item.id),
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
child: CheckboxListTile(
|
||||
value: item.isPurchased,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
onChanged: (_) => _onShoppingItemToggled(item),
|
||||
title: Text(
|
||||
'${item.name} · '
|
||||
'${formatQuantityForDisplay(item.requiredQuantity)} ${item.unit}',
|
||||
style: item.isPurchased
|
||||
? theme.textTheme.bodyLarge?.copyWith(
|
||||
decoration: TextDecoration.lineThrough,
|
||||
color: theme.hintColor,
|
||||
)
|
||||
: theme.textTheme.bodyLarge,
|
||||
),
|
||||
subtitle:
|
||||
item.isAutoGenerated && item.sourceRecipeTitles.isNotEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: _buildSourceBadges(item),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Subtle "From: <recipe>" origin badges for auto-generated rows.
|
||||
Widget _buildSourceBadges(ShoppingListItem item) {
|
||||
final titles = item.sourceRecipeTitles;
|
||||
return Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
for (final title in titles.take(2)) _sourceBadge('From: $title'),
|
||||
if (titles.length > 2) _sourceBadge('+${titles.length - 2} more'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sourceBadge(String label) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.restaurant_menu,
|
||||
size: 12,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(fontSize: 11, color: scheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Check-off flow: ticking an item offers the pantry-stock transfer prompt;
|
||||
/// unchecking flips the flag directly.
|
||||
Future<void> _onShoppingItemToggled(ShoppingListItem item) async {
|
||||
final controller = ref.read(shoppingListControllerProvider.notifier);
|
||||
if (item.isPurchased) {
|
||||
await controller.togglePurchased(item);
|
||||
return;
|
||||
}
|
||||
final addToPantry = await showAddToPantryPrompt(context, item);
|
||||
if (addToPantry == null || !mounted) return;
|
||||
await controller.completeItem(item, addToPantry: addToPantry);
|
||||
}
|
||||
|
||||
Widget _buildEmptyShoppingList(ShoppingListState state) {
|
||||
final String message;
|
||||
final String hint;
|
||||
switch (state.filter) {
|
||||
case ShoppingListFilter.checked:
|
||||
message = 'Nothing checked off yet.';
|
||||
hint = 'Tick items when you buy them.';
|
||||
case ShoppingListFilter.toBuy:
|
||||
message = 'Nothing left to buy.';
|
||||
hint = 'Run the sync to pull in missing recipe ingredients.';
|
||||
case ShoppingListFilter.all:
|
||||
message = 'Your shopping list is clear.';
|
||||
hint = 'Run the sync to pull in missing recipe ingredients.';
|
||||
}
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.checklist,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(message, style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(hint, style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,210 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/utils/date_time_utils.dart';
|
||||
import '../../domain/entities/pantry_category.dart';
|
||||
import '../../domain/value_objects/measurement_units.dart';
|
||||
import '../controllers/pantry_inventory_controller.dart';
|
||||
|
||||
/// Opens the "Add Pantry Item" dialog.
|
||||
///
|
||||
/// Resolves to `true` when the item was persisted, `false` when saving
|
||||
/// failed, and `null` when the user dismissed the dialog.
|
||||
Future<bool?> showAddPantryItemDialog(BuildContext context) => showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => const AddPantryItemDialog(),
|
||||
);
|
||||
|
||||
/// Form for stocking the pantry: name, quantity, unit, grocery aisle, and an
|
||||
/// optional expiration date.
|
||||
class AddPantryItemDialog extends ConsumerStatefulWidget {
|
||||
const AddPantryItemDialog({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AddPantryItemDialog> createState() =>
|
||||
_AddPantryItemDialogState();
|
||||
}
|
||||
|
||||
class _AddPantryItemDialogState extends ConsumerState<AddPantryItemDialog> {
|
||||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
final TextEditingController _quantityController = TextEditingController();
|
||||
|
||||
late String _unit = pantryUnitOptions.first;
|
||||
PantryCategory _category = PantryCategory.other;
|
||||
DateTime? _expirationDate;
|
||||
bool _isSaving = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Add Pantry Item'),
|
||||
content: SizedBox(
|
||||
width: 440,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Name',
|
||||
hintText: 'e.g. chicken breast',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
validator: _nameValidator,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _quantityController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Quantity',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: _quantityValidator,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: _unit,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Unit',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
for (final unit in pantryUnitOptions)
|
||||
DropdownMenuItem(value: unit, child: Text(unit)),
|
||||
],
|
||||
onChanged: (unit) {
|
||||
if (unit == null) return;
|
||||
setState(() => _unit = unit);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<PantryCategory>(
|
||||
initialValue: _category,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Aisle',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
for (final category in PantryCategory.values)
|
||||
DropdownMenuItem(
|
||||
value: category,
|
||||
child: Text(category.displayName),
|
||||
),
|
||||
],
|
||||
onChanged: (category) {
|
||||
if (category == null) return;
|
||||
setState(() => _category = category);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_expirationDate == null
|
||||
? 'No expiration date'
|
||||
: 'Expires ${formatMonthDay(_expirationDate!)}',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
if (_expirationDate != null)
|
||||
IconButton(
|
||||
tooltip: 'Clear date',
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () =>
|
||||
setState(() => _expirationDate = null),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _pickExpirationDate,
|
||||
child: const Text('Pick Date'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _isSaving ? null : () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: _isSaving ? null : _save,
|
||||
icon: _isSaving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.add),
|
||||
label: const Text('Add Item'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickExpirationDate() async {
|
||||
final now = DateTime.now();
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _expirationDate ?? now,
|
||||
firstDate: DateTime(now.year - 1),
|
||||
lastDate: DateTime(now.year + 5),
|
||||
);
|
||||
if (picked == null || !mounted) return;
|
||||
setState(() => _expirationDate = picked);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
setState(() => _isSaving = true);
|
||||
final saved =
|
||||
await ref.read(pantryInventoryControllerProvider.notifier).addItem(
|
||||
name: _nameController.text,
|
||||
quantityOnHand: double.parse(_quantityController.text.trim()),
|
||||
unit: _unit,
|
||||
category: _category,
|
||||
expirationDate: _expirationDate,
|
||||
);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(saved);
|
||||
}
|
||||
|
||||
String? _nameValidator(String? value) {
|
||||
final trimmed = (value ?? '').trim();
|
||||
if (trimmed.isEmpty) return 'Enter a name';
|
||||
if (trimmed.length > 128) return 'Name is too long (max 128)';
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _quantityValidator(String? value) {
|
||||
final parsed = double.tryParse((value ?? '').trim());
|
||||
if (parsed == null || parsed < 0) return 'Enter a valid quantity';
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_quantityController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../domain/value_objects/measurement_units.dart';
|
||||
import '../controllers/shopping_list_controller.dart';
|
||||
|
||||
/// Opens the manual "Add Shopping Item" dialog.
|
||||
///
|
||||
/// Resolves to `true` when the row was persisted, `false` when saving
|
||||
/// failed, and `null` when the user dismissed the dialog.
|
||||
Future<bool?> showAddShoppingItemDialog(BuildContext context) =>
|
||||
showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => const AddShoppingItemDialog(),
|
||||
);
|
||||
|
||||
/// Form for adding a checklist row by hand: name, quantity, and unit.
|
||||
class AddShoppingItemDialog extends ConsumerStatefulWidget {
|
||||
const AddShoppingItemDialog({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AddShoppingItemDialog> createState() =>
|
||||
_AddShoppingItemDialogState();
|
||||
}
|
||||
|
||||
class _AddShoppingItemDialogState
|
||||
extends ConsumerState<AddShoppingItemDialog> {
|
||||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
final TextEditingController _quantityController = TextEditingController();
|
||||
|
||||
late String _unit = pantryUnitOptions.first;
|
||||
bool _isSaving = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Add Shopping Item'),
|
||||
content: SizedBox(
|
||||
width: 440,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Name',
|
||||
hintText: 'e.g. olive oil',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
validator: _nameValidator,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _quantityController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Quantity',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: _quantityValidator,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: _unit,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Unit',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
for (final unit in pantryUnitOptions)
|
||||
DropdownMenuItem(value: unit, child: Text(unit)),
|
||||
],
|
||||
onChanged: (unit) {
|
||||
if (unit == null) return;
|
||||
setState(() => _unit = unit);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _isSaving ? null : () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: _isSaving ? null : _save,
|
||||
icon: _isSaving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.add_shopping_cart),
|
||||
label: const Text('Add Item'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
setState(() => _isSaving = true);
|
||||
final saved =
|
||||
await ref.read(shoppingListControllerProvider.notifier).addItem(
|
||||
name: _nameController.text,
|
||||
quantity: double.parse(_quantityController.text.trim()),
|
||||
unit: _unit,
|
||||
);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(saved);
|
||||
}
|
||||
|
||||
String? _nameValidator(String? value) {
|
||||
final trimmed = (value ?? '').trim();
|
||||
if (trimmed.isEmpty) return 'Enter a name';
|
||||
if (trimmed.length > 128) return 'Name is too long (max 128)';
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _quantityValidator(String? value) {
|
||||
final parsed = double.tryParse((value ?? '').trim());
|
||||
if (parsed == null || parsed <= 0) return 'Enter a quantity above zero';
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_quantityController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../domain/entities/shopping_list_item.dart';
|
||||
import '../../domain/value_objects/measurement_units.dart';
|
||||
|
||||
/// Prompts "Add to Pantry Inventory?" when a checklist item is ticked.
|
||||
///
|
||||
/// Resolves to `true` (check off AND merge into pantry stock), `false`
|
||||
/// (check off only), or `null` (cancel — the item stays unchecked).
|
||||
Future<bool?> showAddToPantryPrompt(
|
||||
BuildContext context,
|
||||
ShoppingListItem item,
|
||||
) =>
|
||||
showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Add to Pantry Inventory?'),
|
||||
content: Text(
|
||||
'Check off "${item.name}" '
|
||||
'(${formatQuantityForDisplay(item.requiredQuantity)} ${item.unit}) '
|
||||
'and add it to your pantry stock?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Check Off Only'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Add to Pantry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -0,0 +1,387 @@
|
|||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:glp1_companion/core/database/app_database.dart';
|
||||
import 'package:glp1_companion/core/utils/date_time_utils.dart';
|
||||
import 'package:glp1_companion/core/utils/uuid.dart';
|
||||
import 'package:glp1_companion/features/meal_planner/data/repositories/drift_meal_plan_repository.dart';
|
||||
import 'package:glp1_companion/features/meal_planner/data/repositories/drift_recipe_repository.dart';
|
||||
import 'package:glp1_companion/features/meal_planner/domain/entities/macro_profile.dart';
|
||||
import 'package:glp1_companion/features/meal_planner/domain/entities/meal_plan_slot.dart';
|
||||
import 'package:glp1_companion/features/meal_planner/domain/entities/recipe.dart';
|
||||
import 'package:glp1_companion/features/meal_planner/domain/entities/recipe_ingredient.dart';
|
||||
import 'package:glp1_companion/features/nutrition/domain/entities/meal_type.dart';
|
||||
import 'package:glp1_companion/features/pantry/data/repositories/drift_pantry_repository.dart';
|
||||
import 'package:glp1_companion/features/pantry/domain/entities/pantry_category.dart';
|
||||
import 'package:glp1_companion/features/pantry/domain/entities/pantry_item.dart';
|
||||
import 'package:glp1_companion/features/pantry/domain/entities/shopping_list_item.dart';
|
||||
import 'package:glp1_companion/features/pantry/domain/usecases/calculate_shopping_delta.dart';
|
||||
import 'package:glp1_companion/features/pantry/presentation/controllers/pantry_inventory_controller.dart';
|
||||
import 'package:glp1_companion/features/pantry/presentation/controllers/shopping_list_controller.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase database;
|
||||
late DriftPantryRepository repository;
|
||||
late CalculateShoppingDelta deltaEngine;
|
||||
late ProviderContainer container;
|
||||
|
||||
/// A Monday (stable fixture for deterministic slot ordering).
|
||||
final weekMonday = DateTime(2026, 10, 12);
|
||||
|
||||
PantryItem makeStock({
|
||||
String? id,
|
||||
required String name,
|
||||
required double quantity,
|
||||
String unit = 'g',
|
||||
PantryCategory category = PantryCategory.other,
|
||||
}) =>
|
||||
PantryItem(
|
||||
id: id ?? generateUuid(),
|
||||
name: name,
|
||||
quantityOnHand: quantity,
|
||||
unit: unit,
|
||||
category: category,
|
||||
lastUpdated: DateTime(2026, 10, 12, 9),
|
||||
);
|
||||
|
||||
Recipe makeRecipe({
|
||||
required String title,
|
||||
required List<RecipeIngredient> ingredients,
|
||||
}) =>
|
||||
Recipe(
|
||||
id: generateUuid(),
|
||||
title: title,
|
||||
sourceUrl: 'https://recipes.test/${generateUuid()}',
|
||||
servings: 4,
|
||||
ingredients: ingredients,
|
||||
instructions: const ['Cook it.'],
|
||||
macrosPerServing: const MacroProfile(
|
||||
calories: 400,
|
||||
proteinGrams: 40,
|
||||
carbsGrams: 20,
|
||||
fatGrams: 15,
|
||||
),
|
||||
);
|
||||
|
||||
MealPlanSlot makeSlot({required DateTime day, required String recipeId}) =>
|
||||
MealPlanSlot(
|
||||
id: generateUuid(),
|
||||
date: day,
|
||||
mealType: MealType.dinner,
|
||||
recipeId: recipeId,
|
||||
);
|
||||
|
||||
RecipeIngredient ingredient(
|
||||
String name,
|
||||
double quantity,
|
||||
String? unit, {
|
||||
String? rawText,
|
||||
}) =>
|
||||
RecipeIngredient(
|
||||
rawText: rawText ?? '$quantity ${unit ?? ''} $name'.trim(),
|
||||
parsedItemName: name,
|
||||
quantity: quantity,
|
||||
unit: unit,
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
database = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
repository = DriftPantryRepository(database);
|
||||
deltaEngine = CalculateShoppingDelta(repository);
|
||||
container = ProviderContainer(
|
||||
overrides: [appDatabaseProvider.overrideWithValue(database)],
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
container.dispose();
|
||||
database.close();
|
||||
});
|
||||
|
||||
group('CalculateShoppingDelta', () {
|
||||
test('300 g on hand vs an 800 g requirement generates a +500 g entry',
|
||||
() async {
|
||||
await repository.saveItem(
|
||||
makeStock(name: 'chicken breast', quantity: 300, unit: 'g'),
|
||||
);
|
||||
final recipe = makeRecipe(
|
||||
title: 'Lemon Herb Chicken',
|
||||
ingredients: [
|
||||
ingredient(
|
||||
'chicken breast',
|
||||
800,
|
||||
'g',
|
||||
rawText: '800 g chicken breast',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final result = await deltaEngine(
|
||||
slots: [makeSlot(day: weekMonday, recipeId: recipe.id)],
|
||||
recipes: [recipe],
|
||||
);
|
||||
|
||||
final list = await repository.getShoppingList();
|
||||
expect(list, hasLength(1));
|
||||
final entry = list.single;
|
||||
expect(entry.name, 'chicken breast');
|
||||
expect(entry.requiredQuantity, 500);
|
||||
expect(entry.unit, 'g');
|
||||
expect(entry.isAutoGenerated, isTrue);
|
||||
expect(entry.isPurchased, isFalse);
|
||||
expect(entry.sourceRecipeTitles, ['Lemon Herb Chicken']);
|
||||
expect(result.addedCount, 1);
|
||||
expect(result.updatedCount, 0);
|
||||
expect(result.removedItems, isEmpty);
|
||||
expect(result.summary, 'Sync complete: 1 added.');
|
||||
});
|
||||
|
||||
test('sufficient stock generates no shopping entry', () async {
|
||||
await repository.saveItem(
|
||||
makeStock(name: 'chicken breast', quantity: 900, unit: 'g'),
|
||||
);
|
||||
final recipe = makeRecipe(
|
||||
title: 'Lemon Herb Chicken',
|
||||
ingredients: [ingredient('chicken breast', 800, 'g')],
|
||||
);
|
||||
|
||||
final result = await deltaEngine(
|
||||
slots: [makeSlot(day: weekMonday, recipeId: recipe.id)],
|
||||
recipes: [recipe],
|
||||
);
|
||||
|
||||
expect(await repository.getShoppingList(), isEmpty);
|
||||
expect(result.addedCount, 0);
|
||||
expect(result.satisfiedNames, ['chicken breast']);
|
||||
expect(
|
||||
result.summary,
|
||||
"Your pantry already covers this week's meal plan.",
|
||||
);
|
||||
});
|
||||
|
||||
test('manual shopping items are preserved when the sync runs', () async {
|
||||
final manual = ShoppingListItem(
|
||||
id: generateUuid(),
|
||||
name: 'Olive Oil',
|
||||
requiredQuantity: 1,
|
||||
unit: 'bottle',
|
||||
);
|
||||
await repository.saveShoppingListItem(manual);
|
||||
await repository.saveItem(
|
||||
makeStock(name: 'chicken breast', quantity: 300, unit: 'g'),
|
||||
);
|
||||
final recipe = makeRecipe(
|
||||
title: 'Lemon Herb Chicken',
|
||||
ingredients: [ingredient('chicken breast', 800, 'g')],
|
||||
);
|
||||
final slots = [makeSlot(day: weekMonday, recipeId: recipe.id)];
|
||||
|
||||
await deltaEngine(slots: slots, recipes: [recipe]);
|
||||
// Re-running the sync must neither drop nor duplicate the manual row.
|
||||
await deltaEngine(slots: slots, recipes: [recipe]);
|
||||
|
||||
final list = await repository.getShoppingList();
|
||||
expect(list, hasLength(2));
|
||||
final restoredManual = list.firstWhere((item) => item.id == manual.id);
|
||||
expect(restoredManual.name, 'Olive Oil');
|
||||
expect(restoredManual.requiredQuantity, 1);
|
||||
expect(restoredManual.unit, 'bottle');
|
||||
expect(restoredManual.isAutoGenerated, isFalse);
|
||||
expect(restoredManual.isPurchased, isFalse);
|
||||
expect(list.where((item) => item.isAutoGenerated), hasLength(1));
|
||||
expect(list.where((item) => item.id == manual.id), hasLength(1));
|
||||
});
|
||||
|
||||
test('stale auto entries are cleared once stock covers the plan', () async {
|
||||
final stock = makeStock(name: 'chicken breast', quantity: 300, unit: 'g');
|
||||
await repository.saveItem(stock);
|
||||
final recipe = makeRecipe(
|
||||
title: 'Lemon Herb Chicken',
|
||||
ingredients: [ingredient('chicken breast', 800, 'g')],
|
||||
);
|
||||
final slots = [makeSlot(day: weekMonday, recipeId: recipe.id)];
|
||||
|
||||
await deltaEngine(slots: slots, recipes: [recipe]);
|
||||
expect(await repository.getShoppingList(), hasLength(1));
|
||||
|
||||
await repository.saveItem(stock.copyWith(quantityOnHand: 900));
|
||||
final result = await deltaEngine(slots: slots, recipes: [recipe]);
|
||||
|
||||
expect(await repository.getShoppingList(), isEmpty);
|
||||
expect(result.removedItems, hasLength(1));
|
||||
expect(result.clearedCount, 1);
|
||||
});
|
||||
|
||||
test('aggregates across recipes and normalizes plurals and units',
|
||||
() async {
|
||||
// Plural/missing-unit lines fold onto one canonical "egg" key:
|
||||
// 4 eggs + 2 egg − 2 in stock = a +4 "egg" deficit.
|
||||
await repository.saveItem(
|
||||
makeStock(name: 'egg', quantity: 2, unit: 'count'),
|
||||
);
|
||||
final breakfast = makeRecipe(
|
||||
title: 'Baked Eggs',
|
||||
ingredients: [ingredient('eggs', 4, null, rawText: '4 eggs')],
|
||||
);
|
||||
final brunch = makeRecipe(
|
||||
title: 'Sunday Omelette',
|
||||
ingredients: [ingredient('egg', 2, null, rawText: '2 egg')],
|
||||
);
|
||||
|
||||
final result = await deltaEngine(
|
||||
slots: [
|
||||
makeSlot(day: weekMonday, recipeId: breakfast.id),
|
||||
makeSlot(
|
||||
day: weekMonday.add(const Duration(days: 1)),
|
||||
recipeId: brunch.id,
|
||||
),
|
||||
],
|
||||
recipes: [breakfast, brunch],
|
||||
);
|
||||
|
||||
final eggEntry = (await repository.getShoppingList())
|
||||
.singleWhere((item) => item.name == 'egg');
|
||||
expect(eggEntry.requiredQuantity, 4);
|
||||
expect(eggEntry.unit, 'count');
|
||||
expect(eggEntry.sourceRecipeTitles, ['Baked Eggs', 'Sunday Omelette']);
|
||||
expect(result.addedCount, 1);
|
||||
});
|
||||
|
||||
test('converts lbs and oz onto one scale before computing the delta',
|
||||
() async {
|
||||
await repository.saveItem(
|
||||
makeStock(name: 'butter', quantity: 4, unit: 'oz'),
|
||||
);
|
||||
final lbRecipe = makeRecipe(
|
||||
title: 'Cake Batter',
|
||||
ingredients: [ingredient('butter', 0.5, 'lb', rawText: '0.5 lb butter')],
|
||||
);
|
||||
final ozRecipe = makeRecipe(
|
||||
title: 'Sauté Night',
|
||||
ingredients: [ingredient('butter', 8, 'oz', rawText: '8 oz butter')],
|
||||
);
|
||||
|
||||
await deltaEngine(
|
||||
slots: [
|
||||
// Earlier date wins the dominant-unit tie (lb is aggregated first).
|
||||
makeSlot(day: weekMonday, recipeId: lbRecipe.id),
|
||||
makeSlot(
|
||||
day: weekMonday.add(const Duration(days: 1)),
|
||||
recipeId: ozRecipe.id,
|
||||
),
|
||||
],
|
||||
recipes: [lbRecipe, ozRecipe],
|
||||
);
|
||||
|
||||
final butter = (await repository.getShoppingList())
|
||||
.singleWhere((item) => item.name == 'butter');
|
||||
expect(butter.unit, 'lb');
|
||||
// (0.5 lb + 8 oz − 4 oz) = 0.75 lb.
|
||||
expect(butter.requiredQuantity, closeTo(0.75, 0.001));
|
||||
});
|
||||
});
|
||||
|
||||
group('ShoppingListController', () {
|
||||
test('checking off with pantry transfer increments stock', () async {
|
||||
await repository.saveItem(
|
||||
makeStock(name: 'chicken breast', quantity: 300, unit: 'g'),
|
||||
);
|
||||
final item = ShoppingListItem(
|
||||
id: generateUuid(),
|
||||
name: 'Chicken Breast',
|
||||
requiredQuantity: 500,
|
||||
unit: 'g',
|
||||
isAutoGenerated: true,
|
||||
sourceRecipeTitles: const ['Lemon Herb Chicken'],
|
||||
);
|
||||
await repository.saveShoppingListItem(item);
|
||||
|
||||
final controller =
|
||||
container.read(shoppingListControllerProvider.notifier);
|
||||
final ok = await controller.completeItem(item, addToPantry: true);
|
||||
expect(ok, isTrue);
|
||||
|
||||
final stock = await repository.getItems();
|
||||
expect(stock, hasLength(1));
|
||||
expect(stock.single.quantityOnHand, 800);
|
||||
expect((await repository.getShoppingList()).single.isPurchased, isTrue);
|
||||
});
|
||||
|
||||
test('checking off a new item creates aisle-inferred pantry stock',
|
||||
() async {
|
||||
final item = ShoppingListItem(
|
||||
id: generateUuid(),
|
||||
name: 'Greek Yogurt',
|
||||
requiredQuantity: 2,
|
||||
unit: 'count',
|
||||
);
|
||||
await repository.saveShoppingListItem(item);
|
||||
|
||||
final controller =
|
||||
container.read(shoppingListControllerProvider.notifier);
|
||||
await controller.completeItem(item, addToPantry: true);
|
||||
|
||||
final stock = await repository.getItems();
|
||||
expect(stock.single.name, 'greek yogurt');
|
||||
expect(stock.single.quantityOnHand, 2);
|
||||
expect(stock.single.unit, 'count');
|
||||
expect(stock.single.category, PantryCategory.dairy);
|
||||
});
|
||||
|
||||
test('syncWithMealPlan wires the planner window into the delta engine',
|
||||
() async {
|
||||
final recipeRepository = DriftRecipeRepository(database);
|
||||
final mealPlanRepository = DriftMealPlanRepository(database);
|
||||
final recipe = makeRecipe(
|
||||
title: 'Lemon Herb Chicken',
|
||||
ingredients: [ingredient('chicken breast', 800, 'g')],
|
||||
);
|
||||
await recipeRepository.saveRecipe(recipe);
|
||||
await mealPlanRepository.saveSlot(
|
||||
makeSlot(day: startOfWeek(DateTime.now()), recipeId: recipe.id),
|
||||
);
|
||||
|
||||
final controller =
|
||||
container.read(shoppingListControllerProvider.notifier);
|
||||
final summary = await controller.syncWithMealPlan();
|
||||
|
||||
expect(summary, isNotNull);
|
||||
expect(
|
||||
container.read(shoppingListControllerProvider).statusMessage,
|
||||
summary,
|
||||
);
|
||||
final list = await repository.getShoppingList();
|
||||
expect(list, hasLength(1));
|
||||
expect(list.single.isAutoGenerated, isTrue);
|
||||
expect(list.single.requiredQuantity, 800);
|
||||
});
|
||||
});
|
||||
|
||||
group('PantryInventoryController', () {
|
||||
test('addItem canonicalizes the name and quick adjust clamps at zero',
|
||||
() async {
|
||||
final controller =
|
||||
container.read(pantryInventoryControllerProvider.notifier);
|
||||
final saved = await controller.addItem(
|
||||
name: 'Chicken Breasts',
|
||||
quantityOnHand: 300,
|
||||
unit: 'g',
|
||||
category: PantryCategory.meatSeafood,
|
||||
);
|
||||
expect(saved, isTrue);
|
||||
|
||||
final stock = await repository.getItems();
|
||||
expect(stock.single.name, 'chicken breast');
|
||||
expect(stock.single.category, PantryCategory.meatSeafood);
|
||||
|
||||
await controller.adjustQuantity(stock.single, -500);
|
||||
expect((await repository.getItems()).single.quantityOnHand, 0);
|
||||
|
||||
await controller.adjustQuantity(
|
||||
(await repository.getItems()).single,
|
||||
50,
|
||||
);
|
||||
expect((await repository.getItems()).single.quantityOnHand, 50);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue