diff --git a/BUILD_STATE.md b/BUILD_STATE.md index 8195cba..df644b3 100644 --- a/BUILD_STATE.md +++ b/BUILD_STATE.md @@ -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: " 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) \ No newline at end of file diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart index c874fb0..e1cc5ea 100644 --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -177,6 +177,71 @@ class MealPlanSlotsTable extends Table { Set? 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? 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` of contributing recipe titles for + /// auto-generated rows; `null` for manual entries. + TextColumn get sourceNotes => text().nullable()(); + + @override + Set? 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> watchPantryItems() => + _selectPantryItems().watch(); + + /// One-shot snapshot of pantry stock (alphabetical). + Future> getPantryItems() => _selectPantryItems().get(); + + /// The pantry row with [id], or `null` when absent. + Future 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 upsertPantryItem(PantryItemsTableCompanion entry) => + into(pantryItemsTable).insertOnConflictUpdate(entry); + + /// Deletes the pantry row with [id], if present. + Future 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> watchShoppingListItems() => + _selectShoppingListItems().watch(); + + /// One-shot snapshot of the shopping checklist (unchecked first). + Future> getShoppingListItems() => + _selectShoppingListItems().get(); + + /// The checklist row with [id], or `null` when absent. + Future 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 upsertShoppingListItem(ShoppingListItemsTableCompanion entry) => + into(shoppingListItemsTable).insertOnConflictUpdate(entry); + + /// Deletes the checklist row with [id], if present. + Future 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. diff --git a/lib/core/database/app_database.g.dart b/lib/core/database/app_database.g.dart index bda9870..028bc64 100644 --- a/lib/core/database/app_database.g.dart +++ b/lib/core/database/app_database.g.dart @@ -2551,6 +2551,847 @@ class MealPlanSlotsTableCompanion extends UpdateCompanion { } } +class $PantryItemsTableTable extends PantryItemsTable + with TableInfo<$PantryItemsTableTable, PantryItemRecord> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $PantryItemsTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, false, + additionalChecks: + GeneratedColumn.checkTextLength(minTextLength: 1, maxTextLength: 128), + type: DriftSqlType.string, + requiredDuringInsert: true); + static const VerificationMeta _quantityOnHandMeta = + const VerificationMeta('quantityOnHand'); + @override + late final GeneratedColumn quantityOnHand = GeneratedColumn( + 'quantity_on_hand', aliasedName, false, + type: DriftSqlType.double, requiredDuringInsert: true); + static const VerificationMeta _unitMeta = const VerificationMeta('unit'); + @override + late final GeneratedColumn unit = GeneratedColumn( + 'unit', aliasedName, false, + additionalChecks: + GeneratedColumn.checkTextLength(minTextLength: 1, maxTextLength: 16), + type: DriftSqlType.string, + requiredDuringInsert: true); + static const VerificationMeta _categoryMeta = + const VerificationMeta('category'); + @override + late final GeneratedColumn category = GeneratedColumn( + 'category', aliasedName, false, + additionalChecks: + GeneratedColumn.checkTextLength(minTextLength: 1, maxTextLength: 32), + type: DriftSqlType.string, + requiredDuringInsert: true); + static const VerificationMeta _expirationDateMeta = + const VerificationMeta('expirationDate'); + @override + late final GeneratedColumn expirationDate = + GeneratedColumn('expiration_date', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + static const VerificationMeta _lastUpdatedMeta = + const VerificationMeta('lastUpdated'); + @override + late final GeneratedColumn lastUpdated = GeneratedColumn( + 'last_updated', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + @override + List get $columns => + [id, name, quantityOnHand, unit, category, expirationDate, lastUpdated]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'pantry_items_table'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('name')) { + context.handle( + _nameMeta, name.isAcceptableOrUnknown(data['name']!, _nameMeta)); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('quantity_on_hand')) { + context.handle( + _quantityOnHandMeta, + quantityOnHand.isAcceptableOrUnknown( + data['quantity_on_hand']!, _quantityOnHandMeta)); + } else if (isInserting) { + context.missing(_quantityOnHandMeta); + } + if (data.containsKey('unit')) { + context.handle( + _unitMeta, unit.isAcceptableOrUnknown(data['unit']!, _unitMeta)); + } else if (isInserting) { + context.missing(_unitMeta); + } + if (data.containsKey('category')) { + context.handle(_categoryMeta, + category.isAcceptableOrUnknown(data['category']!, _categoryMeta)); + } else if (isInserting) { + context.missing(_categoryMeta); + } + if (data.containsKey('expiration_date')) { + context.handle( + _expirationDateMeta, + expirationDate.isAcceptableOrUnknown( + data['expiration_date']!, _expirationDateMeta)); + } + if (data.containsKey('last_updated')) { + context.handle( + _lastUpdatedMeta, + lastUpdated.isAcceptableOrUnknown( + data['last_updated']!, _lastUpdatedMeta)); + } else if (isInserting) { + context.missing(_lastUpdatedMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + PantryItemRecord map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PantryItemRecord( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + name: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}name'])!, + quantityOnHand: attachedDatabase.typeMapping.read( + DriftSqlType.double, data['${effectivePrefix}quantity_on_hand'])!, + unit: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}unit'])!, + category: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}category'])!, + expirationDate: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, data['${effectivePrefix}expiration_date']), + lastUpdated: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}last_updated'])!, + ); + } + + @override + $PantryItemsTableTable createAlias(String alias) { + return $PantryItemsTableTable(attachedDatabase, alias); + } +} + +class PantryItemRecord extends DataClass + implements Insertable { + /// UUID primary key (see `core/utils/uuid.dart`). + final String id; + + /// Canonical lowercase ingredient name (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 key (e.g. `meat_seafood`) — the `persistenceKey` of the + /// pantry domain's category enum. + final String category; + + /// Best-by date; `null` when the item does not expire. + final DateTime? expirationDate; + final DateTime lastUpdated; + const PantryItemRecord( + {required this.id, + required this.name, + required this.quantityOnHand, + required this.unit, + required this.category, + this.expirationDate, + required this.lastUpdated}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['quantity_on_hand'] = Variable(quantityOnHand); + map['unit'] = Variable(unit); + map['category'] = Variable(category); + if (!nullToAbsent || expirationDate != null) { + map['expiration_date'] = Variable(expirationDate); + } + map['last_updated'] = Variable(lastUpdated); + return map; + } + + PantryItemsTableCompanion toCompanion(bool nullToAbsent) { + return PantryItemsTableCompanion( + id: Value(id), + name: Value(name), + quantityOnHand: Value(quantityOnHand), + unit: Value(unit), + category: Value(category), + expirationDate: expirationDate == null && nullToAbsent + ? const Value.absent() + : Value(expirationDate), + lastUpdated: Value(lastUpdated), + ); + } + + factory PantryItemRecord.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PantryItemRecord( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + quantityOnHand: serializer.fromJson(json['quantityOnHand']), + unit: serializer.fromJson(json['unit']), + category: serializer.fromJson(json['category']), + expirationDate: serializer.fromJson(json['expirationDate']), + lastUpdated: serializer.fromJson(json['lastUpdated']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'quantityOnHand': serializer.toJson(quantityOnHand), + 'unit': serializer.toJson(unit), + 'category': serializer.toJson(category), + 'expirationDate': serializer.toJson(expirationDate), + 'lastUpdated': serializer.toJson(lastUpdated), + }; + } + + PantryItemRecord copyWith( + {String? id, + String? name, + double? quantityOnHand, + String? unit, + String? category, + Value expirationDate = const Value.absent(), + DateTime? lastUpdated}) => + PantryItemRecord( + id: id ?? this.id, + name: name ?? this.name, + quantityOnHand: quantityOnHand ?? this.quantityOnHand, + unit: unit ?? this.unit, + category: category ?? this.category, + expirationDate: + expirationDate.present ? expirationDate.value : this.expirationDate, + lastUpdated: lastUpdated ?? this.lastUpdated, + ); + PantryItemRecord copyWithCompanion(PantryItemsTableCompanion data) { + return PantryItemRecord( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + quantityOnHand: data.quantityOnHand.present + ? data.quantityOnHand.value + : this.quantityOnHand, + unit: data.unit.present ? data.unit.value : this.unit, + category: data.category.present ? data.category.value : this.category, + expirationDate: data.expirationDate.present + ? data.expirationDate.value + : this.expirationDate, + lastUpdated: + data.lastUpdated.present ? data.lastUpdated.value : this.lastUpdated, + ); + } + + @override + String toString() { + return (StringBuffer('PantryItemRecord(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('quantityOnHand: $quantityOnHand, ') + ..write('unit: $unit, ') + ..write('category: $category, ') + ..write('expirationDate: $expirationDate, ') + ..write('lastUpdated: $lastUpdated') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, name, quantityOnHand, unit, category, expirationDate, lastUpdated); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PantryItemRecord && + other.id == this.id && + other.name == this.name && + other.quantityOnHand == this.quantityOnHand && + other.unit == this.unit && + other.category == this.category && + other.expirationDate == this.expirationDate && + other.lastUpdated == this.lastUpdated); +} + +class PantryItemsTableCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value quantityOnHand; + final Value unit; + final Value category; + final Value expirationDate; + final Value lastUpdated; + final Value rowid; + const PantryItemsTableCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.quantityOnHand = const Value.absent(), + this.unit = const Value.absent(), + this.category = const Value.absent(), + this.expirationDate = const Value.absent(), + this.lastUpdated = const Value.absent(), + this.rowid = const Value.absent(), + }); + PantryItemsTableCompanion.insert({ + required String id, + required String name, + required double quantityOnHand, + required String unit, + required String category, + this.expirationDate = const Value.absent(), + required DateTime lastUpdated, + this.rowid = const Value.absent(), + }) : id = Value(id), + name = Value(name), + quantityOnHand = Value(quantityOnHand), + unit = Value(unit), + category = Value(category), + lastUpdated = Value(lastUpdated); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? quantityOnHand, + Expression? unit, + Expression? category, + Expression? expirationDate, + Expression? lastUpdated, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (quantityOnHand != null) 'quantity_on_hand': quantityOnHand, + if (unit != null) 'unit': unit, + if (category != null) 'category': category, + if (expirationDate != null) 'expiration_date': expirationDate, + if (lastUpdated != null) 'last_updated': lastUpdated, + if (rowid != null) 'rowid': rowid, + }); + } + + PantryItemsTableCompanion copyWith( + {Value? id, + Value? name, + Value? quantityOnHand, + Value? unit, + Value? category, + Value? expirationDate, + Value? lastUpdated, + Value? rowid}) { + return PantryItemsTableCompanion( + 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, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (quantityOnHand.present) { + map['quantity_on_hand'] = Variable(quantityOnHand.value); + } + if (unit.present) { + map['unit'] = Variable(unit.value); + } + if (category.present) { + map['category'] = Variable(category.value); + } + if (expirationDate.present) { + map['expiration_date'] = Variable(expirationDate.value); + } + if (lastUpdated.present) { + map['last_updated'] = Variable(lastUpdated.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PantryItemsTableCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('quantityOnHand: $quantityOnHand, ') + ..write('unit: $unit, ') + ..write('category: $category, ') + ..write('expirationDate: $expirationDate, ') + ..write('lastUpdated: $lastUpdated, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $ShoppingListItemsTableTable extends ShoppingListItemsTable + with TableInfo<$ShoppingListItemsTableTable, ShoppingListItemRecord> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ShoppingListItemsTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, false, + additionalChecks: + GeneratedColumn.checkTextLength(minTextLength: 1, maxTextLength: 128), + type: DriftSqlType.string, + requiredDuringInsert: true); + static const VerificationMeta _requiredQuantityMeta = + const VerificationMeta('requiredQuantity'); + @override + late final GeneratedColumn requiredQuantity = GeneratedColumn( + 'required_quantity', aliasedName, false, + type: DriftSqlType.double, requiredDuringInsert: true); + static const VerificationMeta _unitMeta = const VerificationMeta('unit'); + @override + late final GeneratedColumn unit = GeneratedColumn( + 'unit', aliasedName, false, + additionalChecks: + GeneratedColumn.checkTextLength(minTextLength: 1, maxTextLength: 16), + type: DriftSqlType.string, + requiredDuringInsert: true); + static const VerificationMeta _isPurchasedMeta = + const VerificationMeta('isPurchased'); + @override + late final GeneratedColumn isPurchased = GeneratedColumn( + 'is_purchased', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_purchased" IN (0, 1))'), + defaultValue: const Constant(false)); + static const VerificationMeta _isAutoGeneratedMeta = + const VerificationMeta('isAutoGenerated'); + @override + late final GeneratedColumn isAutoGenerated = GeneratedColumn( + 'is_auto_generated', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_auto_generated" IN (0, 1))'), + defaultValue: const Constant(false)); + static const VerificationMeta _sourceNotesMeta = + const VerificationMeta('sourceNotes'); + @override + late final GeneratedColumn sourceNotes = GeneratedColumn( + 'source_notes', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + @override + List get $columns => [ + id, + name, + requiredQuantity, + unit, + isPurchased, + isAutoGenerated, + sourceNotes + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'shopping_list_items_table'; + @override + VerificationContext validateIntegrity( + Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('name')) { + context.handle( + _nameMeta, name.isAcceptableOrUnknown(data['name']!, _nameMeta)); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('required_quantity')) { + context.handle( + _requiredQuantityMeta, + requiredQuantity.isAcceptableOrUnknown( + data['required_quantity']!, _requiredQuantityMeta)); + } else if (isInserting) { + context.missing(_requiredQuantityMeta); + } + if (data.containsKey('unit')) { + context.handle( + _unitMeta, unit.isAcceptableOrUnknown(data['unit']!, _unitMeta)); + } else if (isInserting) { + context.missing(_unitMeta); + } + if (data.containsKey('is_purchased')) { + context.handle( + _isPurchasedMeta, + isPurchased.isAcceptableOrUnknown( + data['is_purchased']!, _isPurchasedMeta)); + } + if (data.containsKey('is_auto_generated')) { + context.handle( + _isAutoGeneratedMeta, + isAutoGenerated.isAcceptableOrUnknown( + data['is_auto_generated']!, _isAutoGeneratedMeta)); + } + if (data.containsKey('source_notes')) { + context.handle( + _sourceNotesMeta, + sourceNotes.isAcceptableOrUnknown( + data['source_notes']!, _sourceNotesMeta)); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + ShoppingListItemRecord map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ShoppingListItemRecord( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + name: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}name'])!, + requiredQuantity: attachedDatabase.typeMapping.read( + DriftSqlType.double, data['${effectivePrefix}required_quantity'])!, + unit: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}unit'])!, + isPurchased: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}is_purchased'])!, + isAutoGenerated: attachedDatabase.typeMapping.read( + DriftSqlType.bool, data['${effectivePrefix}is_auto_generated'])!, + sourceNotes: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}source_notes']), + ); + } + + @override + $ShoppingListItemsTableTable createAlias(String alias) { + return $ShoppingListItemsTableTable(attachedDatabase, alias); + } +} + +class ShoppingListItemRecord extends DataClass + implements Insertable { + /// UUID primary key. + final String id; + final String name; + + /// Amount needed, expressed in [unit]. + final double requiredQuantity; + + /// Canonical unit key (e.g. `g`, `count`). + final String unit; + final bool isPurchased; + final bool isAutoGenerated; + + /// JSON-encoded `List` of contributing recipe titles for + /// auto-generated rows; `null` for manual entries. + final String? sourceNotes; + const ShoppingListItemRecord( + {required this.id, + required this.name, + required this.requiredQuantity, + required this.unit, + required this.isPurchased, + required this.isAutoGenerated, + this.sourceNotes}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['required_quantity'] = Variable(requiredQuantity); + map['unit'] = Variable(unit); + map['is_purchased'] = Variable(isPurchased); + map['is_auto_generated'] = Variable(isAutoGenerated); + if (!nullToAbsent || sourceNotes != null) { + map['source_notes'] = Variable(sourceNotes); + } + return map; + } + + ShoppingListItemsTableCompanion toCompanion(bool nullToAbsent) { + return ShoppingListItemsTableCompanion( + id: Value(id), + name: Value(name), + requiredQuantity: Value(requiredQuantity), + unit: Value(unit), + isPurchased: Value(isPurchased), + isAutoGenerated: Value(isAutoGenerated), + sourceNotes: sourceNotes == null && nullToAbsent + ? const Value.absent() + : Value(sourceNotes), + ); + } + + factory ShoppingListItemRecord.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ShoppingListItemRecord( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + requiredQuantity: serializer.fromJson(json['requiredQuantity']), + unit: serializer.fromJson(json['unit']), + isPurchased: serializer.fromJson(json['isPurchased']), + isAutoGenerated: serializer.fromJson(json['isAutoGenerated']), + sourceNotes: serializer.fromJson(json['sourceNotes']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'requiredQuantity': serializer.toJson(requiredQuantity), + 'unit': serializer.toJson(unit), + 'isPurchased': serializer.toJson(isPurchased), + 'isAutoGenerated': serializer.toJson(isAutoGenerated), + 'sourceNotes': serializer.toJson(sourceNotes), + }; + } + + ShoppingListItemRecord copyWith( + {String? id, + String? name, + double? requiredQuantity, + String? unit, + bool? isPurchased, + bool? isAutoGenerated, + Value sourceNotes = const Value.absent()}) => + ShoppingListItemRecord( + id: id ?? this.id, + name: name ?? this.name, + requiredQuantity: requiredQuantity ?? this.requiredQuantity, + unit: unit ?? this.unit, + isPurchased: isPurchased ?? this.isPurchased, + isAutoGenerated: isAutoGenerated ?? this.isAutoGenerated, + sourceNotes: sourceNotes.present ? sourceNotes.value : this.sourceNotes, + ); + ShoppingListItemRecord copyWithCompanion( + ShoppingListItemsTableCompanion data) { + return ShoppingListItemRecord( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + requiredQuantity: data.requiredQuantity.present + ? data.requiredQuantity.value + : this.requiredQuantity, + unit: data.unit.present ? data.unit.value : this.unit, + isPurchased: + data.isPurchased.present ? data.isPurchased.value : this.isPurchased, + isAutoGenerated: data.isAutoGenerated.present + ? data.isAutoGenerated.value + : this.isAutoGenerated, + sourceNotes: + data.sourceNotes.present ? data.sourceNotes.value : this.sourceNotes, + ); + } + + @override + String toString() { + return (StringBuffer('ShoppingListItemRecord(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('requiredQuantity: $requiredQuantity, ') + ..write('unit: $unit, ') + ..write('isPurchased: $isPurchased, ') + ..write('isAutoGenerated: $isAutoGenerated, ') + ..write('sourceNotes: $sourceNotes') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, name, requiredQuantity, unit, isPurchased, + isAutoGenerated, sourceNotes); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ShoppingListItemRecord && + other.id == this.id && + other.name == this.name && + other.requiredQuantity == this.requiredQuantity && + other.unit == this.unit && + other.isPurchased == this.isPurchased && + other.isAutoGenerated == this.isAutoGenerated && + other.sourceNotes == this.sourceNotes); +} + +class ShoppingListItemsTableCompanion + extends UpdateCompanion { + final Value id; + final Value name; + final Value requiredQuantity; + final Value unit; + final Value isPurchased; + final Value isAutoGenerated; + final Value sourceNotes; + final Value rowid; + const ShoppingListItemsTableCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.requiredQuantity = const Value.absent(), + this.unit = const Value.absent(), + this.isPurchased = const Value.absent(), + this.isAutoGenerated = const Value.absent(), + this.sourceNotes = const Value.absent(), + this.rowid = const Value.absent(), + }); + ShoppingListItemsTableCompanion.insert({ + required String id, + required String name, + required double requiredQuantity, + required String unit, + this.isPurchased = const Value.absent(), + this.isAutoGenerated = const Value.absent(), + this.sourceNotes = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + name = Value(name), + requiredQuantity = Value(requiredQuantity), + unit = Value(unit); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? requiredQuantity, + Expression? unit, + Expression? isPurchased, + Expression? isAutoGenerated, + Expression? sourceNotes, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (requiredQuantity != null) 'required_quantity': requiredQuantity, + if (unit != null) 'unit': unit, + if (isPurchased != null) 'is_purchased': isPurchased, + if (isAutoGenerated != null) 'is_auto_generated': isAutoGenerated, + if (sourceNotes != null) 'source_notes': sourceNotes, + if (rowid != null) 'rowid': rowid, + }); + } + + ShoppingListItemsTableCompanion copyWith( + {Value? id, + Value? name, + Value? requiredQuantity, + Value? unit, + Value? isPurchased, + Value? isAutoGenerated, + Value? sourceNotes, + Value? rowid}) { + return ShoppingListItemsTableCompanion( + id: id ?? this.id, + name: name ?? this.name, + requiredQuantity: requiredQuantity ?? this.requiredQuantity, + unit: unit ?? this.unit, + isPurchased: isPurchased ?? this.isPurchased, + isAutoGenerated: isAutoGenerated ?? this.isAutoGenerated, + sourceNotes: sourceNotes ?? this.sourceNotes, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (requiredQuantity.present) { + map['required_quantity'] = Variable(requiredQuantity.value); + } + if (unit.present) { + map['unit'] = Variable(unit.value); + } + if (isPurchased.present) { + map['is_purchased'] = Variable(isPurchased.value); + } + if (isAutoGenerated.present) { + map['is_auto_generated'] = Variable(isAutoGenerated.value); + } + if (sourceNotes.present) { + map['source_notes'] = Variable(sourceNotes.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ShoppingListItemsTableCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('requiredQuantity: $requiredQuantity, ') + ..write('unit: $unit, ') + ..write('isPurchased: $isPurchased, ') + ..write('isAutoGenerated: $isAutoGenerated, ') + ..write('sourceNotes: $sourceNotes, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); $AppDatabaseManager get managers => $AppDatabaseManager(this); @@ -2564,6 +3405,10 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $RecipesTableTable recipesTable = $RecipesTableTable(this); late final $MealPlanSlotsTableTable mealPlanSlotsTable = $MealPlanSlotsTableTable(this); + late final $PantryItemsTableTable pantryItemsTable = + $PantryItemsTableTable(this); + late final $ShoppingListItemsTableTable shoppingListItemsTable = + $ShoppingListItemsTableTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -2574,7 +3419,9 @@ abstract class _$AppDatabase extends GeneratedDatabase { hydrationLogsTable, biometricLogsTable, recipesTable, - mealPlanSlotsTable + mealPlanSlotsTable, + pantryItemsTable, + shoppingListItemsTable ]; @override StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules( @@ -4056,6 +4903,438 @@ typedef $$MealPlanSlotsTableTableProcessedTableManager = ProcessedTableManager< (MealPlanSlotRecord, $$MealPlanSlotsTableTableReferences), MealPlanSlotRecord, PrefetchHooks Function({bool recipeId})>; +typedef $$PantryItemsTableTableCreateCompanionBuilder + = PantryItemsTableCompanion Function({ + required String id, + required String name, + required double quantityOnHand, + required String unit, + required String category, + Value expirationDate, + required DateTime lastUpdated, + Value rowid, +}); +typedef $$PantryItemsTableTableUpdateCompanionBuilder + = PantryItemsTableCompanion Function({ + Value id, + Value name, + Value quantityOnHand, + Value unit, + Value category, + Value expirationDate, + Value lastUpdated, + Value rowid, +}); + +class $$PantryItemsTableTableFilterComposer + extends Composer<_$AppDatabase, $PantryItemsTableTable> { + $$PantryItemsTableTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get name => $composableBuilder( + column: $table.name, builder: (column) => ColumnFilters(column)); + + ColumnFilters get quantityOnHand => $composableBuilder( + column: $table.quantityOnHand, + builder: (column) => ColumnFilters(column)); + + ColumnFilters get unit => $composableBuilder( + column: $table.unit, builder: (column) => ColumnFilters(column)); + + ColumnFilters get category => $composableBuilder( + column: $table.category, builder: (column) => ColumnFilters(column)); + + ColumnFilters get expirationDate => $composableBuilder( + column: $table.expirationDate, + builder: (column) => ColumnFilters(column)); + + ColumnFilters get lastUpdated => $composableBuilder( + column: $table.lastUpdated, builder: (column) => ColumnFilters(column)); +} + +class $$PantryItemsTableTableOrderingComposer + extends Composer<_$AppDatabase, $PantryItemsTableTable> { + $$PantryItemsTableTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get name => $composableBuilder( + column: $table.name, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get quantityOnHand => $composableBuilder( + column: $table.quantityOnHand, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get unit => $composableBuilder( + column: $table.unit, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get category => $composableBuilder( + column: $table.category, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get expirationDate => $composableBuilder( + column: $table.expirationDate, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get lastUpdated => $composableBuilder( + column: $table.lastUpdated, builder: (column) => ColumnOrderings(column)); +} + +class $$PantryItemsTableTableAnnotationComposer + extends Composer<_$AppDatabase, $PantryItemsTableTable> { + $$PantryItemsTableTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumn get quantityOnHand => $composableBuilder( + column: $table.quantityOnHand, builder: (column) => column); + + GeneratedColumn get unit => + $composableBuilder(column: $table.unit, builder: (column) => column); + + GeneratedColumn get category => + $composableBuilder(column: $table.category, builder: (column) => column); + + GeneratedColumn get expirationDate => $composableBuilder( + column: $table.expirationDate, builder: (column) => column); + + GeneratedColumn get lastUpdated => $composableBuilder( + column: $table.lastUpdated, builder: (column) => column); +} + +class $$PantryItemsTableTableTableManager extends RootTableManager< + _$AppDatabase, + $PantryItemsTableTable, + PantryItemRecord, + $$PantryItemsTableTableFilterComposer, + $$PantryItemsTableTableOrderingComposer, + $$PantryItemsTableTableAnnotationComposer, + $$PantryItemsTableTableCreateCompanionBuilder, + $$PantryItemsTableTableUpdateCompanionBuilder, + ( + PantryItemRecord, + BaseReferences<_$AppDatabase, $PantryItemsTableTable, PantryItemRecord> + ), + PantryItemRecord, + PrefetchHooks Function()> { + $$PantryItemsTableTableTableManager( + _$AppDatabase db, $PantryItemsTableTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$PantryItemsTableTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$PantryItemsTableTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$PantryItemsTableTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value name = const Value.absent(), + Value quantityOnHand = const Value.absent(), + Value unit = const Value.absent(), + Value category = const Value.absent(), + Value expirationDate = const Value.absent(), + Value lastUpdated = const Value.absent(), + Value rowid = const Value.absent(), + }) => + PantryItemsTableCompanion( + id: id, + name: name, + quantityOnHand: quantityOnHand, + unit: unit, + category: category, + expirationDate: expirationDate, + lastUpdated: lastUpdated, + rowid: rowid, + ), + createCompanionCallback: ({ + required String id, + required String name, + required double quantityOnHand, + required String unit, + required String category, + Value expirationDate = const Value.absent(), + required DateTime lastUpdated, + Value rowid = const Value.absent(), + }) => + PantryItemsTableCompanion.insert( + id: id, + name: name, + quantityOnHand: quantityOnHand, + unit: unit, + category: category, + expirationDate: expirationDate, + lastUpdated: lastUpdated, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => ( + e.readTable<$PantryItemsTableTable, PantryItemRecord>( + table), + BaseReferences<_$AppDatabase, $PantryItemsTableTable, + PantryItemRecord>(db, table, e) + )) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$PantryItemsTableTableProcessedTableManager = ProcessedTableManager< + _$AppDatabase, + $PantryItemsTableTable, + PantryItemRecord, + $$PantryItemsTableTableFilterComposer, + $$PantryItemsTableTableOrderingComposer, + $$PantryItemsTableTableAnnotationComposer, + $$PantryItemsTableTableCreateCompanionBuilder, + $$PantryItemsTableTableUpdateCompanionBuilder, + ( + PantryItemRecord, + BaseReferences<_$AppDatabase, $PantryItemsTableTable, PantryItemRecord> + ), + PantryItemRecord, + PrefetchHooks Function()>; +typedef $$ShoppingListItemsTableTableCreateCompanionBuilder + = ShoppingListItemsTableCompanion Function({ + required String id, + required String name, + required double requiredQuantity, + required String unit, + Value isPurchased, + Value isAutoGenerated, + Value sourceNotes, + Value rowid, +}); +typedef $$ShoppingListItemsTableTableUpdateCompanionBuilder + = ShoppingListItemsTableCompanion Function({ + Value id, + Value name, + Value requiredQuantity, + Value unit, + Value isPurchased, + Value isAutoGenerated, + Value sourceNotes, + Value rowid, +}); + +class $$ShoppingListItemsTableTableFilterComposer + extends Composer<_$AppDatabase, $ShoppingListItemsTableTable> { + $$ShoppingListItemsTableTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get name => $composableBuilder( + column: $table.name, builder: (column) => ColumnFilters(column)); + + ColumnFilters get requiredQuantity => $composableBuilder( + column: $table.requiredQuantity, + builder: (column) => ColumnFilters(column)); + + ColumnFilters get unit => $composableBuilder( + column: $table.unit, builder: (column) => ColumnFilters(column)); + + ColumnFilters get isPurchased => $composableBuilder( + column: $table.isPurchased, builder: (column) => ColumnFilters(column)); + + ColumnFilters get isAutoGenerated => $composableBuilder( + column: $table.isAutoGenerated, + builder: (column) => ColumnFilters(column)); + + ColumnFilters get sourceNotes => $composableBuilder( + column: $table.sourceNotes, builder: (column) => ColumnFilters(column)); +} + +class $$ShoppingListItemsTableTableOrderingComposer + extends Composer<_$AppDatabase, $ShoppingListItemsTableTable> { + $$ShoppingListItemsTableTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get name => $composableBuilder( + column: $table.name, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get requiredQuantity => $composableBuilder( + column: $table.requiredQuantity, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get unit => $composableBuilder( + column: $table.unit, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get isPurchased => $composableBuilder( + column: $table.isPurchased, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get isAutoGenerated => $composableBuilder( + column: $table.isAutoGenerated, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get sourceNotes => $composableBuilder( + column: $table.sourceNotes, builder: (column) => ColumnOrderings(column)); +} + +class $$ShoppingListItemsTableTableAnnotationComposer + extends Composer<_$AppDatabase, $ShoppingListItemsTableTable> { + $$ShoppingListItemsTableTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumn get requiredQuantity => $composableBuilder( + column: $table.requiredQuantity, builder: (column) => column); + + GeneratedColumn get unit => + $composableBuilder(column: $table.unit, builder: (column) => column); + + GeneratedColumn get isPurchased => $composableBuilder( + column: $table.isPurchased, builder: (column) => column); + + GeneratedColumn get isAutoGenerated => $composableBuilder( + column: $table.isAutoGenerated, builder: (column) => column); + + GeneratedColumn get sourceNotes => $composableBuilder( + column: $table.sourceNotes, builder: (column) => column); +} + +class $$ShoppingListItemsTableTableTableManager extends RootTableManager< + _$AppDatabase, + $ShoppingListItemsTableTable, + ShoppingListItemRecord, + $$ShoppingListItemsTableTableFilterComposer, + $$ShoppingListItemsTableTableOrderingComposer, + $$ShoppingListItemsTableTableAnnotationComposer, + $$ShoppingListItemsTableTableCreateCompanionBuilder, + $$ShoppingListItemsTableTableUpdateCompanionBuilder, + ( + ShoppingListItemRecord, + BaseReferences<_$AppDatabase, $ShoppingListItemsTableTable, + ShoppingListItemRecord> + ), + ShoppingListItemRecord, + PrefetchHooks Function()> { + $$ShoppingListItemsTableTableTableManager( + _$AppDatabase db, $ShoppingListItemsTableTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ShoppingListItemsTableTableFilterComposer( + $db: db, $table: table), + createOrderingComposer: () => + $$ShoppingListItemsTableTableOrderingComposer( + $db: db, $table: table), + createComputedFieldComposer: () => + $$ShoppingListItemsTableTableAnnotationComposer( + $db: db, $table: table), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value name = const Value.absent(), + Value requiredQuantity = const Value.absent(), + Value unit = const Value.absent(), + Value isPurchased = const Value.absent(), + Value isAutoGenerated = const Value.absent(), + Value sourceNotes = const Value.absent(), + Value rowid = const Value.absent(), + }) => + ShoppingListItemsTableCompanion( + id: id, + name: name, + requiredQuantity: requiredQuantity, + unit: unit, + isPurchased: isPurchased, + isAutoGenerated: isAutoGenerated, + sourceNotes: sourceNotes, + rowid: rowid, + ), + createCompanionCallback: ({ + required String id, + required String name, + required double requiredQuantity, + required String unit, + Value isPurchased = const Value.absent(), + Value isAutoGenerated = const Value.absent(), + Value sourceNotes = const Value.absent(), + Value rowid = const Value.absent(), + }) => + ShoppingListItemsTableCompanion.insert( + id: id, + name: name, + requiredQuantity: requiredQuantity, + unit: unit, + isPurchased: isPurchased, + isAutoGenerated: isAutoGenerated, + sourceNotes: sourceNotes, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => ( + e.readTable<$ShoppingListItemsTableTable, + ShoppingListItemRecord>(table), + BaseReferences<_$AppDatabase, $ShoppingListItemsTableTable, + ShoppingListItemRecord>(db, table, e) + )) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$ShoppingListItemsTableTableProcessedTableManager + = ProcessedTableManager< + _$AppDatabase, + $ShoppingListItemsTableTable, + ShoppingListItemRecord, + $$ShoppingListItemsTableTableFilterComposer, + $$ShoppingListItemsTableTableOrderingComposer, + $$ShoppingListItemsTableTableAnnotationComposer, + $$ShoppingListItemsTableTableCreateCompanionBuilder, + $$ShoppingListItemsTableTableUpdateCompanionBuilder, + ( + ShoppingListItemRecord, + BaseReferences<_$AppDatabase, $ShoppingListItemsTableTable, + ShoppingListItemRecord> + ), + ShoppingListItemRecord, + PrefetchHooks Function()>; class $AppDatabaseManager { final _$AppDatabase _db; @@ -4072,4 +5351,9 @@ class $AppDatabaseManager { $$RecipesTableTableTableManager(_db, _db.recipesTable); $$MealPlanSlotsTableTableTableManager get mealPlanSlotsTable => $$MealPlanSlotsTableTableTableManager(_db, _db.mealPlanSlotsTable); + $$PantryItemsTableTableTableManager get pantryItemsTable => + $$PantryItemsTableTableTableManager(_db, _db.pantryItemsTable); + $$ShoppingListItemsTableTableTableManager get shoppingListItemsTable => + $$ShoppingListItemsTableTableTableManager( + _db, _db.shoppingListItemsTable); } diff --git a/lib/features/pantry/data/repositories/drift_pantry_repository.dart b/lib/features/pantry/data/repositories/drift_pantry_repository.dart new file mode 100644 index 0000000..bc0440c --- /dev/null +++ b/lib/features/pantry/data/repositories/drift_pantry_repository.dart @@ -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> watchItems() => _database + .watchPantryItems() + .map((records) => records.map(_mapItem).toList(growable: false)); + + @override + Future> getItems() async => + (await _database.getPantryItems()).map(_mapItem).toList(growable: false); + + @override + Future getItem(String id) async { + final record = await _database.getPantryItemById(id); + return record == null ? null : _mapItem(record); + } + + @override + Future 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 deleteItem(String id) => _database.deletePantryItem(id); + + // --------------------------------------------------------------------------- + // Shopping list + // --------------------------------------------------------------------------- + + @override + Stream> watchShoppingList() => _database + .watchShoppingListItems() + .map((records) => records.map(_mapShoppingItem).toList(growable: false)); + + @override + Future> getShoppingList() async => + (await _database.getShoppingListItems()) + .map(_mapShoppingItem) + .toList(growable: false); + + @override + Future getShoppingListItem(String id) async { + final record = await _database.getShoppingListItemById(id); + return record == null ? null : _mapShoppingItem(record); + } + + @override + Future 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 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 _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 []; + } + } +} \ No newline at end of file diff --git a/lib/features/pantry/domain/entities/pantry_category.dart b/lib/features/pantry/domain/entities/pantry_category.dart new file mode 100644 index 0000000..952356a --- /dev/null +++ b/lib/features/pantry/domain/entities/pantry_category.dart @@ -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 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', + ]), +]; \ No newline at end of file diff --git a/lib/features/pantry/domain/entities/pantry_item.dart b/lib/features/pantry/domain/entities/pantry_item.dart new file mode 100644 index 0000000..cb5030f --- /dev/null +++ b/lib/features/pantry/domain/entities/pantry_item.dart @@ -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})'; +} \ No newline at end of file diff --git a/lib/features/pantry/domain/entities/shopping_list_item.dart b/lib/features/pantry/domain/entities/shopping_list_item.dart new file mode 100644 index 0000000..969b368 --- /dev/null +++ b/lib/features/pantry/domain/entities/shopping_list_item.dart @@ -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 sourceRecipeTitles; + + ShoppingListItem copyWith({ + String? id, + String? name, + double? requiredQuantity, + String? unit, + bool? isPurchased, + bool? isAutoGenerated, + List? 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)'; +} \ No newline at end of file diff --git a/lib/features/pantry/domain/repositories/pantry_repository.dart b/lib/features/pantry/domain/repositories/pantry_repository.dart new file mode 100644 index 0000000..ee6caa2 --- /dev/null +++ b/lib/features/pantry/domain/repositories/pantry_repository.dart @@ -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> watchItems(); + + /// One-shot snapshot of pantry stock (alphabetical). + Future> getItems(); + + /// The inventory row with [id], or `null` when absent. + Future getItem(String id); + + /// Inserts or updates [item] (keyed by `PantryItem.id`). + Future saveItem(PantryItem item); + + /// Removes the inventory row with [id], if present. + Future deleteItem(String id); + + /// Emits the shopping checklist (unchecked rows first) whenever it changes. + Stream> watchShoppingList(); + + /// One-shot snapshot of the shopping checklist (unchecked first). + Future> getShoppingList(); + + /// The checklist row with [id], or `null` when absent. + Future getShoppingListItem(String id); + + /// Inserts or updates [item] (keyed by `ShoppingListItem.id`). + Future saveShoppingListItem(ShoppingListItem item); + + /// Removes the checklist row with [id], if present. + Future deleteShoppingListItem(String id); +} \ No newline at end of file diff --git a/lib/features/pantry/domain/usecases/calculate_shopping_delta.dart b/lib/features/pantry/domain/usecases/calculate_shopping_delta.dart new file mode 100644 index 0000000..1dfd641 --- /dev/null +++ b/lib/features/pantry/domain/usecases/calculate_shopping_delta.dart @@ -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 generatedItems; + + /// Existing auto-generated rows refreshed with a new deficit. + final List updatedItems; + + /// Auto-generated rows removed because their requirement vanished + /// (pantry now covers it, or a manual row owns the name). + final List removedItems; + + /// Manual rows observed during the sync — always left untouched. + final List preservedManualItems; + + /// Canonical ingredient names fully covered by current pantry stock. + final List satisfiedNames; + + /// Canonical ingredient names skipped because a manual row exists. + final List 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 = [ + 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 baseByUnit = {}; + + /// Deduplicated recipe titles contributing to this requirement. + final List sourceTitles = []; +} + +/// 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 call({ + required List slots, + required List 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 = >{}; + for (final item in pantry) { + stockByName + .putIfAbsent(canonicalIngredientName(item.name), () => []) + .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 = {}; + final autoByName = {}; + for (final item in existing) { + final key = canonicalIngredientName(item.name); + if (item.isAutoGenerated) { + autoByName.putIfAbsent(key, () => item); + } else { + manualByName.putIfAbsent(key, () => item); + } + } + + final generated = []; + final updated = []; + final toSave = []; + final satisfied = []; + final coveredByManual = []; + + 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.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 = [ + 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 _aggregateRequirements( + List slots, + List recipes, + ) { + final recipesById = { + for (final recipe in recipes) recipe.id: recipe, + }; + final orderedSlots = List.of(slots) + ..sort((a, b) { + final byDate = a.date.compareTo(b.date); + return byDate != 0 ? byDate : a.id.compareTo(b.id); + }); + + final requirements = {}; + 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'; +} + diff --git a/lib/features/pantry/domain/value_objects/ingredient_name.dart b/lib/features/pantry/domain/value_objects/ingredient_name.dart new file mode 100644 index 0000000..3070669 --- /dev/null +++ b/lib/features/pantry/domain/value_objects/ingredient_name.dart @@ -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(' '); +} \ No newline at end of file diff --git a/lib/features/pantry/domain/value_objects/measurement_units.dart b/lib/features/pantry/domain/value_objects/measurement_units.dart new file mode 100644 index 0000000..e3b4028 --- /dev/null +++ b/lib/features/pantry/domain/value_objects/measurement_units.dart @@ -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 _massUnits = {'mg', 'g', 'kg', 'oz', 'lb'}; + +/// Units expressed in millilitres at the base scale. +const Set _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 _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 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(); +} \ No newline at end of file diff --git a/lib/features/pantry/presentation/controllers/pantry_inventory_controller.dart b/lib/features/pantry/presentation/controllers/pantry_inventory_controller.dart new file mode 100644 index 0000000..81aaa5a --- /dev/null +++ b/lib/features/pantry/presentation/controllers/pantry_inventory_controller.dart @@ -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( + (ref) => DriftPantryRepository(ref.watch(appDatabaseProvider)), +); + +/// How the inventory list is organized on screen. +enum PantrySortMode { category, expiration } + +/// Immutable snapshot of the pantry inventory state. +class PantryInventoryState { + const PantryInventoryState({ + this.items = const [], + this.sortMode = PantrySortMode.category, + this.hasLoaded = false, + this.errorMessage, + }); + + /// Sentinel allowing `copyWith` to explicitly reset a field to `null`. + static const Object _keep = Object(); + + /// Pantry rows (alphabetical, kept live by the repository watch stream). + final List items; + + /// Active list organization mode (aisles vs. expiration). + final PantrySortMode sortMode; + + /// `true` once the first watch emission arrived (drives the spinner). + final bool hasLoaded; + + /// Last load/persist failure message. + final String? errorMessage; + + /// Non-empty aisles in category order for sectioned rendering. + Map> get groupedByCategory { + final grouped = >{}; + for (final category in PantryCategory.values) { + final bucket = items + .where((item) => item.category == category) + .toList(growable: false); + if (bucket.isNotEmpty) grouped[category] = bucket; + } + return grouped; + } + + /// Items sorted soonest-expiration first; undated items sink to the bottom. + List get byExpiration { + final sorted = List.of(items); + sorted.sort((a, b) { + final aDate = a.expirationDate; + final bDate = b.expirationDate; + if (aDate == null && bDate == null) return a.name.compareTo(b.name); + if (aDate == null) return 1; + if (bDate == null) return -1; + final byDate = aDate.compareTo(bDate); + return byDate != 0 ? byDate : a.name.compareTo(b.name); + }); + return sorted; + } + + /// Items already expired or inside the warning window (drives badges). + List get expiringSoonItems { + final flagged = []; + for (final item in items) { + final status = item.expirationStatus(); + if (status == ExpirationStatus.expired || + status == ExpirationStatus.expiringSoon) { + flagged.add(item); + } + } + return flagged; + } + + PantryInventoryState copyWith({ + List? items, + PantrySortMode? sortMode, + bool? hasLoaded, + Object? errorMessage = _keep, + }) => + PantryInventoryState( + items: items ?? this.items, + sortMode: sortMode ?? this.sortMode, + hasLoaded: hasLoaded ?? this.hasLoaded, + errorMessage: identical(errorMessage, _keep) + ? this.errorMessage + : errorMessage as String?, + ); +} + +/// Orchestrates the pantry inventory: streams stock from the repository, +/// supports quick increment/decrement adjustments, and owns the +/// aisle/expiration presentation modes. +class PantryInventoryController extends Notifier { + @override + PantryInventoryState build() { + final subscription = ref + .read(pantryRepositoryProvider) + .watchItems() + .listen( + (items) => state = state.copyWith(items: items, hasLoaded: true), + onError: (Object error, StackTrace stackTrace) { + debugPrintStack( + stackTrace: stackTrace, + label: 'pantry inventory watch failed', + ); + state = state.copyWith(errorMessage: 'Could not load the pantry.'); + }, + ); + ref.onDispose(subscription.cancel); + return const PantryInventoryState(); + } + + /// Adds a new stock row; [name] is canonicalized for fuzzy recipe matching. + Future addItem({ + required String name, + required double quantityOnHand, + required String unit, + required PantryCategory category, + DateTime? expirationDate, + }) async { + final key = canonicalIngredientName(name); + if (key.isEmpty || quantityOnHand < 0) { + state = state.copyWith(errorMessage: 'Enter a name and a valid quantity.'); + return false; + } + try { + await ref.read(pantryRepositoryProvider).saveItem( + PantryItem( + id: generateUuid(), + name: key, + quantityOnHand: quantityOnHand, + unit: unit, + category: category, + expirationDate: expirationDate, + lastUpdated: DateTime.now(), + ), + ); + state = state.copyWith(errorMessage: null); + return true; + } catch (error, stackTrace) { + debugPrintStack(stackTrace: stackTrace, label: 'pantry item save failed'); + state = state.copyWith(errorMessage: 'Could not save the item.'); + return false; + } + } + + /// Quick +/- stock adjustment for [item]; the level is clamped at zero. + Future adjustQuantity(PantryItem item, double delta) async { + final next = item.quantityOnHand + delta; + try { + await ref.read(pantryRepositoryProvider).saveItem( + item.copyWith( + quantityOnHand: next < 0 ? 0 : next, + lastUpdated: DateTime.now(), + ), + ); + } catch (error, stackTrace) { + debugPrintStack( + stackTrace: stackTrace, + label: 'pantry quantity adjust failed', + ); + state = state.copyWith(errorMessage: 'Could not update the stock level.'); + } + } + + /// Removes the inventory row with [id]. + Future deleteItem(String id) async { + try { + await ref.read(pantryRepositoryProvider).deleteItem(id); + } catch (error, stackTrace) { + debugPrintStack( + stackTrace: stackTrace, + label: 'pantry item delete failed', + ); + state = state.copyWith(errorMessage: 'Could not delete the item.'); + } + } + + /// Switches the list organization mode (aisles vs. expiration). + void setSortMode(PantrySortMode mode) => + state = state.copyWith(sortMode: mode); + + /// Clears the last error (e.g. once the user has seen it). + void clearError() => state = state.copyWith(errorMessage: null); +} + +/// Exposes the [PantryInventoryController] to the widget tree. +final pantryInventoryControllerProvider = + NotifierProvider( + PantryInventoryController.new, +); \ No newline at end of file diff --git a/lib/features/pantry/presentation/controllers/shopping_list_controller.dart b/lib/features/pantry/presentation/controllers/shopping_list_controller.dart new file mode 100644 index 0000000..b6b7131 --- /dev/null +++ b/lib/features/pantry/presentation/controllers/shopping_list_controller.dart @@ -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( + (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 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 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? 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 { + @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 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 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 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 _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 _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 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 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.new, +); \ No newline at end of file diff --git a/lib/features/pantry/presentation/screens/pantry_screen.dart b/lib/features/pantry/presentation/screens/pantry_screen.dart index 5a26028..704e188 100644 --- a/lib/features/pantry/presentation/screens/pantry_screen.dart +++ b/lib/features/pantry/presentation/screens/pantry_screen.dart @@ -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 createState() => _PantryScreenState(); +} + +class _PantryScreenState extends ConsumerState + 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 _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 _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( + 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 _buildCategorySections(PantryInventoryState state) { + final sections = []; + 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( + 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 _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: " 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 _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), ], ), ); diff --git a/lib/features/pantry/presentation/widgets/add_pantry_item_dialog.dart b/lib/features/pantry/presentation/widgets/add_pantry_item_dialog.dart new file mode 100644 index 0000000..d995e2f --- /dev/null +++ b/lib/features/pantry/presentation/widgets/add_pantry_item_dialog.dart @@ -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 showAddPantryItemDialog(BuildContext context) => showDialog( + 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 createState() => + _AddPantryItemDialogState(); +} + +class _AddPantryItemDialogState extends ConsumerState { + final GlobalKey _formKey = GlobalKey(); + 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( + 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( + 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 _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 _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(); + } +} \ No newline at end of file diff --git a/lib/features/pantry/presentation/widgets/add_shopping_item_dialog.dart b/lib/features/pantry/presentation/widgets/add_shopping_item_dialog.dart new file mode 100644 index 0000000..96a20c8 --- /dev/null +++ b/lib/features/pantry/presentation/widgets/add_shopping_item_dialog.dart @@ -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 showAddShoppingItemDialog(BuildContext context) => + showDialog( + 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 createState() => + _AddShoppingItemDialogState(); +} + +class _AddShoppingItemDialogState + extends ConsumerState { + final GlobalKey _formKey = GlobalKey(); + 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( + 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 _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(); + } +} \ No newline at end of file diff --git a/lib/features/pantry/presentation/widgets/add_to_pantry_prompt.dart b/lib/features/pantry/presentation/widgets/add_to_pantry_prompt.dart new file mode 100644 index 0000000..00450be --- /dev/null +++ b/lib/features/pantry/presentation/widgets/add_to_pantry_prompt.dart @@ -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 showAddToPantryPrompt( + BuildContext context, + ShoppingListItem item, +) => + showDialog( + 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'), + ), + ], + ), + ); \ No newline at end of file diff --git a/test/features/pantry/delta_calculator_test.dart b/test/features/pantry/delta_calculator_test.dart new file mode 100644 index 0000000..3b70c86 --- /dev/null +++ b/test/features/pantry/delta_calculator_test.dart @@ -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 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); + }); + }); +} \ No newline at end of file