feat(pantry): complete Phase 5 pantry delta engine and auto-shopping generator

Domain (pure Dart):
- Add PantryItem and ShoppingListItem entities plus the PantryCategory aisle
  enum (produce/meat_seafood/dairy/dry_goods/spices/other) with a keyword
  infer() heuristic and ExpirationStatus warning buckets (expired /
  expiringSoon within 3 days)
- Add measurement_units value object (mass/volume/count dimensions with
  lb/oz/g/kg and ml/l/tsp/tbsp/cup conversion onto a shared base scale) and
  ingredient_name canonicalizer (lowercase, parenthetical stripping,
  plural folding: eggs -> egg, lbs -> lb)
- Define IPantryRepository contract covering inventory and checklist CRUD
- Add CalculateShoppingDelta use case: aggregates the active 7-day meal-plan
  slot window's recipe ingredient manifests by canonical name, computes
  deficit = totalRequired - currentPantryQuantity in base units (dominant
  recipe unit wins display, 2-decimal rounding), upserts isAutoGenerated
  rows with stable ids and source recipe titles, clears stale auto rows once
  stock covers the plan, and never modifies or removes manual rows

Data:
- Bump AppDatabase to schema v4 (migration for v1-v3 installs) with
  PantryItemsTable (UUID PK, canonical name, quantity/unit, aisle key,
  nullable expirationDate, lastUpdated) and ShoppingListItemsTable (UUID PK,
  required quantity/unit, purchased/auto flags, JSON sourceNotes for recipe
  titles) plus watch/get/upsert/delete helpers
- Implement DriftPantryRepository (record <-> entity mapping, enum keys,
  JSON recipe-title round-trip)

Presentation:
- Add PantryInventoryController (live watch stream, quick +/- stock steps
  clamped at zero, aisle-grouping vs expiration-sort modes) and
  ShoppingListController (manual add, All/To Buy/Checked filter, check-off
  with "Add to Pantry?" unit-converting stock merge or aisle-inferred row
  creation, syncWithMealPlan reading the planner's Monday-Sunday window and
  recipe catalog)
- Refactor PantryScreen: Inventory/Shopping tab header with remaining-count
  badge, Aisles/Expiration segmented control, categorized tiles with +/-
  counters and expired/expiring badges, swipe-to-delete on both lists,
  prominent "Sync with Meal Plan" banner with run summary + snackbar,
  "From: <recipe>" origin chips on auto rows, checklist filters, check-off
  -> pantry prompt, and Add Pantry Item / Add Shopping Item dialogs

Testing:
- 10 new tests in delta_calculator_test.dart: 300 g on hand vs 800 g
  requirement -> +500 g entry, sufficient stock -> no entry, manual rows
  preserved across repeated syncs, stale auto cleanup, cross-recipe
  plural/unit normalization (4 eggs + 2 egg - 2 -> +4 egg), lb/oz scale
  unification (0.75 lb), check-off -> pantry transfer (merge +
  aisle-inferred create), end-to-end syncWithMealPlan, and inventory
  canonicalization / quick-adjust clamp; full suite green (99/99)
- flutter analyze clean (No issues found!) after codegen; BUILD_STATE.md:
  mark Phase 5 complete and record all verification gates
This commit is contained in:
Mike Kell 2026-10-16 10:33:01 -04:00
parent bdee2de35f
commit 747d917aed
18 changed files with 4291 additions and 39 deletions

View File

@ -1,15 +1,15 @@
# Build Execution Ledger # Build Execution Ledger
## Active Target ## Active Target
- **Current Milestone:** Phase 4: Recipe Web Scraper Engine & Meal Planning Calendar — COMPLETE - **Current Milestone:** Phase 5: Pantry Delta Engine & Auto-Shopping Generator — COMPLETE
- **Status:** Phase 4 Complete (next: Phase 5) - **Status:** Phase 5 Complete (next: Phase 6)
## Phase Checklist ## Phase Checklist
- [x] Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB) - [x] Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB)
- [x] Phase 2: Medication Domain, Injection Rotation, Serum Modeling - [x] Phase 2: Medication Domain, Injection Rotation, Serum Modeling
- [x] Phase 3: Nutrition, Protein Target Tracking, HealthGateway Sync - [x] Phase 3: Nutrition, Protein Target Tracking, HealthGateway Sync
- [x] Phase 4: Recipe Web Scraper Engine & Meal Planning Calendar - [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 6: Hardening, Native Desktop/Web Polish, CI/CD
## Phase 2 Deliverables (2026-10-16) ## Phase 2 Deliverables (2026-10-16)
@ -45,8 +45,16 @@
## Post-Phase-4 Additions ## 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). - [x] Desktop resize overflow fix (2026-10-16): the hatched yellow/black stripes (RenderFlex overflow) shown when shrinking the Windows window are gone — wide-mode day columns now scroll vertically (matching the compact pager), and a `LayoutBuilder` in `MealPlannerScreen` collapses the page into a single scrollable column (with a fixed-height calendar) whenever the body is shorter than ~520 dp. Covered by `test/features/meal_planner/meal_planner_screen_test.dart` (4 widget tests pumping the screen at short-compact, short-wide, tall, and extremely-short window sizes; overflow errors fail widget tests, so green proves no stripes).
## Phase 5 Deliverables (2026-10-16)
- [x] Domain: `PantryItem` (+ `PantryCategory` aisle enum with `infer()` keyword heuristic, `ExpirationStatus` warning buckets), `ShoppingListItem`, `IPantryRepository`, and two pure-Dart value objects — `ingredient_name.dart` (lowercase, paren-stripped, plural-folded canonical keys: `eggs` → `egg`) and `measurement_units.dart` (mass/volume/count dimensions with lb↔oz↔g↔kg and ml/l/tsp/tbsp/cup conversion).
- [x] Domain: `CalculateShoppingDelta` use case — aggregates the active 7-day `MealPlanSlot` window's `Recipe` ingredient manifests by canonical name, applies `deficit = totalRequired − currentPantryQuantity` in dimension base units (dominant recipe unit wins display, 2-decimal rounding), upserts `isAutoGenerated` rows with `sourceRecipeTitles` (stable ids, checked-state kept only while the requirement is unchanged), clears stale auto rows (satisfied by stock or superseded by a manual row), and never modifies or removes manual rows.
- [x] Data: `AppDatabase` schema v4 (migration for v1–v3 installs) with `PantryItemsTable` (UUID PK, canonical name, quantity/unit, aisle key, nullable `expirationDate`, `lastUpdated`) and `ShoppingListItemsTable` (UUID PK, required quantity/unit, purchased/auto flags, JSON `sourceNotes` for recipe titles); drift-backed `DriftPantryRepository` (watch/get/upsert/delete for both lists, enum/JSON mapping).
- [x] Presentation: `PantryInventoryController` (live watch stream, quick ± stock steps clamped at zero, aisle-grouping vs. expiration-sort modes) and `ShoppingListController` (manual add, All/To Buy/Checked filter, check-off with "Add to Pantry?" prompt performing a unit-converting stock merge or aisle-inferred row creation, `syncWithMealPlan` reading the planner's Monday–Sunday window + recipe catalog).
- [x] Presentation: `PantryScreen` refactor — Inventory/Shopping tab header with remaining-count badge, Aisles/Expiration segmented control, categorized tiles with +/− counters and Expired/expiring badges, swipe-to-delete on both lists, prominent "Sync with Meal Plan" banner with run summary + snackbar, auto-row "From: <recipe>" origin chips, filter segments, check-off → pantry prompt, and Add Pantry Item / Add Shopping Item dialogs.
- [x] Tests: 10 new tests in `test/features/pantry/delta_calculator_test.dart` — the three required scenarios (300 g vs 800 g → +500 g entry; sufficient stock → no entry; manual rows preserved across repeated syncs) plus stale auto cleanup, cross-recipe plural/unit normalization (4 eggs + 2 egg − 2 → +4 egg), lb/oz scale unification (0.75 lb), check-off → pantry transfer (merge + aisle-inferred create), end-to-end `syncWithMealPlan`, and inventory canonicalization/quick-adjust clamp.
## Active Verification Gates ## Active Verification Gates
- [x] `flutter pub run build_runner build --delete-conflicting-outputs` - [x] `flutter pub run build_runner build --delete-conflicting-outputs`
- [x] `flutter analyze` (Zero issues — `No issues found!`) - [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) - [x] `flutter build web --no-tree-shake-icons` (conditional import keeps `package:health`/`dart:io` out of the web bundle)

View File

@ -177,6 +177,71 @@ class MealPlanSlotsTable extends Table {
Set<Column>? get primaryKey => {id}; Set<Column>? get primaryKey => {id};
} }
/// A pantry stock record: how much of an ingredient is on hand, its grocery
/// aisle, and when it expires.
///
/// [name] stores the canonical lowercase ingredient key (see
/// `features/pantry/domain/value_objects/ingredient_name.dart`) so the delta
/// engine can fuzzy-match recipe lines against stock. The `core` layer never
/// imports feature code.
@DataClassName('PantryItemRecord')
class PantryItemsTable extends Table {
/// UUID primary key (see `core/utils/uuid.dart`).
TextColumn get id => text()();
/// Canonical lowercase ingredient name (e.g. `chicken breast`).
TextColumn get name => text().withLength(min: 1, max: 128)();
/// Quantity currently on hand, expressed in [unit].
RealColumn get quantityOnHand => real()();
/// Canonical unit key (e.g. `g`, `oz`, `count`).
TextColumn get unit => text().withLength(min: 1, max: 16)();
/// Grocery aisle key (e.g. `meat_seafood`) — the `persistenceKey` of the
/// pantry domain's category enum.
TextColumn get category => text().withLength(min: 1, max: 32)();
/// Best-by date; `null` when the item does not expire.
DateTimeColumn get expirationDate => dateTime().nullable()();
DateTimeColumn get lastUpdated => dateTime()();
@override
Set<Column>? get primaryKey => {id};
}
/// One row of the shopping checklist.
///
/// Rows flagged [isAutoGenerated] are owned by the delta engine and
/// recomputed on every meal-plan sync; manual rows are never touched by
/// syncs.
@DataClassName('ShoppingListItemRecord')
class ShoppingListItemsTable extends Table {
/// UUID primary key.
TextColumn get id => text()();
TextColumn get name => text().withLength(min: 1, max: 128)();
/// Amount needed, expressed in [unit].
RealColumn get requiredQuantity => real()();
/// Canonical unit key (e.g. `g`, `count`).
TextColumn get unit => text().withLength(min: 1, max: 16)();
BoolColumn get isPurchased => boolean().withDefault(const Constant(false))();
BoolColumn get isAutoGenerated =>
boolean().withDefault(const Constant(false))();
/// JSON-encoded `List<String>` of contributing recipe titles for
/// auto-generated rows; `null` for manual entries.
TextColumn get sourceNotes => text().nullable()();
@override
Set<Column>? get primaryKey => {id};
}
/// Root drift database for the GLP-1 Companion. /// Root drift database for the GLP-1 Companion.
/// ///
/// On native platforms the connection is backed by SQLCipher: the 256-bit AES /// On native platforms the connection is backed by SQLCipher: the 256-bit AES
@ -192,6 +257,8 @@ class MealPlanSlotsTable extends Table {
BiometricLogsTable, BiometricLogsTable,
RecipesTable, RecipesTable,
MealPlanSlotsTable, MealPlanSlotsTable,
PantryItemsTable,
ShoppingListItemsTable,
], ],
) )
class AppDatabase extends _$AppDatabase { class AppDatabase extends _$AppDatabase {
@ -202,7 +269,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase.forTesting(super.executor); AppDatabase.forTesting(super.executor);
@override @override
int get schemaVersion => 3; int get schemaVersion => 4;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@ -221,6 +288,11 @@ class AppDatabase extends _$AppDatabase {
await m.createTable(recipesTable); await m.createTable(recipesTable);
await m.createTable(mealPlanSlotsTable); 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.plannedDate),
(t) => OrderingTerm.asc(t.id), (t) => OrderingTerm.asc(t.id),
]); ]);
// ---------------------------------------------------------------------------
// Pantry inventory CRUD
// ---------------------------------------------------------------------------
//
// Records/companions only: mapping to the pantry feature's `PantryItem`
// entity happens in `features/pantry/data/repositories/`.
/// Emits pantry stock (alphabetical) and re-emits on every write.
Stream<List<PantryItemRecord>> watchPantryItems() =>
_selectPantryItems().watch();
/// One-shot snapshot of pantry stock (alphabetical).
Future<List<PantryItemRecord>> getPantryItems() => _selectPantryItems().get();
/// The pantry row with [id], or `null` when absent.
Future<PantryItemRecord?> getPantryItemById(String id) =>
(select(pantryItemsTable)..where((t) => t.id.equals(id)))
.getSingleOrNull();
/// Inserts the pantry row or updates the existing row with the same id.
Future<void> upsertPantryItem(PantryItemsTableCompanion entry) =>
into(pantryItemsTable).insertOnConflictUpdate(entry);
/// Deletes the pantry row with [id], if present.
Future<int> deletePantryItem(String id) =>
(delete(pantryItemsTable)..where((t) => t.id.equals(id))).go();
/// Alphabetical query shared by the watch/get helpers.
SimpleSelectStatement<$PantryItemsTableTable, PantryItemRecord>
_selectPantryItems() => select(pantryItemsTable)
..orderBy([
(t) => OrderingTerm.asc(t.name),
(t) => OrderingTerm.asc(t.id),
]);
// ---------------------------------------------------------------------------
// Shopping list CRUD
// ---------------------------------------------------------------------------
/// Emits the shopping checklist (unchecked rows first) and re-emits on
/// every write.
Stream<List<ShoppingListItemRecord>> watchShoppingListItems() =>
_selectShoppingListItems().watch();
/// One-shot snapshot of the shopping checklist (unchecked first).
Future<List<ShoppingListItemRecord>> getShoppingListItems() =>
_selectShoppingListItems().get();
/// The checklist row with [id], or `null` when absent.
Future<ShoppingListItemRecord?> getShoppingListItemById(String id) =>
(select(shoppingListItemsTable)..where((t) => t.id.equals(id)))
.getSingleOrNull();
/// Inserts the checklist row or updates the existing row with the same id.
Future<void> upsertShoppingListItem(ShoppingListItemsTableCompanion entry) =>
into(shoppingListItemsTable).insertOnConflictUpdate(entry);
/// Deletes the checklist row with [id], if present.
Future<int> deleteShoppingListItem(String id) =>
(delete(shoppingListItemsTable)..where((t) => t.id.equals(id))).go();
/// Purchase-state query shared by the watch/get helpers.
SimpleSelectStatement<$ShoppingListItemsTableTable, ShoppingListItemRecord>
_selectShoppingListItems() => select(shoppingListItemsTable)
..orderBy([
(t) => OrderingTerm.asc(t.isPurchased),
(t) => OrderingTerm.asc(t.name),
(t) => OrderingTerm.asc(t.id),
]);
} }
/// Application-wide database instance; closed when the container is disposed. /// Application-wide database instance; closed when the container is disposed.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,143 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import '../../../../core/database/app_database.dart';
import '../../domain/entities/pantry_category.dart';
import '../../domain/entities/pantry_item.dart';
import '../../domain/entities/shopping_list_item.dart';
import '../../domain/repositories/pantry_repository.dart';
/// Drift-backed implementation of [IPantryRepository].
///
/// Owns the translation between persistence records ([PantryItemRecord],
/// [ShoppingListItemRecord]) and the pure-Dart pantry entities. The aisle
/// persists as its stable enum key and auto-generated recipe sources
/// round-trip through a JSON column, so the core schema never imports
/// feature code.
class DriftPantryRepository implements IPantryRepository {
DriftPantryRepository(this._database);
final AppDatabase _database;
// ---------------------------------------------------------------------------
// Pantry inventory
// ---------------------------------------------------------------------------
@override
Stream<List<PantryItem>> watchItems() => _database
.watchPantryItems()
.map((records) => records.map(_mapItem).toList(growable: false));
@override
Future<List<PantryItem>> getItems() async =>
(await _database.getPantryItems()).map(_mapItem).toList(growable: false);
@override
Future<PantryItem?> getItem(String id) async {
final record = await _database.getPantryItemById(id);
return record == null ? null : _mapItem(record);
}
@override
Future<PantryItem> saveItem(PantryItem item) async {
await _database.upsertPantryItem(
PantryItemsTableCompanion(
id: Value(item.id),
name: Value(item.name),
quantityOnHand: Value(item.quantityOnHand),
unit: Value(item.unit),
category: Value(item.category.persistenceKey),
expirationDate: Value(item.expirationDate),
lastUpdated: Value(item.lastUpdated),
),
);
return item;
}
@override
Future<void> deleteItem(String id) => _database.deletePantryItem(id);
// ---------------------------------------------------------------------------
// Shopping list
// ---------------------------------------------------------------------------
@override
Stream<List<ShoppingListItem>> watchShoppingList() => _database
.watchShoppingListItems()
.map((records) => records.map(_mapShoppingItem).toList(growable: false));
@override
Future<List<ShoppingListItem>> getShoppingList() async =>
(await _database.getShoppingListItems())
.map(_mapShoppingItem)
.toList(growable: false);
@override
Future<ShoppingListItem?> getShoppingListItem(String id) async {
final record = await _database.getShoppingListItemById(id);
return record == null ? null : _mapShoppingItem(record);
}
@override
Future<ShoppingListItem> saveShoppingListItem(ShoppingListItem item) async {
await _database.upsertShoppingListItem(
ShoppingListItemsTableCompanion(
id: Value(item.id),
name: Value(item.name),
requiredQuantity: Value(item.requiredQuantity),
unit: Value(item.unit),
isPurchased: Value(item.isPurchased),
isAutoGenerated: Value(item.isAutoGenerated),
sourceNotes: Value(
item.sourceRecipeTitles.isEmpty
? null
: jsonEncode(item.sourceRecipeTitles),
),
),
);
return item;
}
@override
Future<void> deleteShoppingListItem(String id) =>
_database.deleteShoppingListItem(id);
// ---------------------------------------------------------------------------
// Mapping
// ---------------------------------------------------------------------------
PantryItem _mapItem(PantryItemRecord record) => PantryItem(
id: record.id,
name: record.name,
quantityOnHand: record.quantityOnHand,
unit: record.unit,
category: PantryCategory.parse(record.category),
expirationDate: record.expirationDate,
lastUpdated: record.lastUpdated,
);
ShoppingListItem _mapShoppingItem(ShoppingListItemRecord record) =>
ShoppingListItem(
id: record.id,
name: record.name,
requiredQuantity: record.requiredQuantity,
unit: record.unit,
isPurchased: record.isPurchased,
isAutoGenerated: record.isAutoGenerated,
sourceRecipeTitles: _decodeSources(record.sourceNotes),
);
/// Decodes the JSON recipe-title list; malformed or absent payloads fall
/// back to an empty list (manual rows carry `null`).
List<String> _decodeSources(String? raw) {
if (raw == null || raw.isEmpty) return const [];
try {
final decoded = jsonDecode(raw);
if (decoded is! List) return const [];
return decoded.map((title) => title.toString()).toList(growable: false);
} on FormatException {
return const [];
}
}
}

View File

@ -0,0 +1,92 @@
/// Grocery-aisle taxonomy for pantry inventory and generated shopping items.
///
/// Pure Dart (no framework imports). The enum persists via
/// [persistenceKey] (e.g. `meat_seafood`) so the drift schema stores a
/// stable string decoupled from the Dart identifier.
enum PantryCategory {
produce('produce'),
meatSeafood('meat_seafood'),
dairy('dairy'),
dryGoods('dry_goods'),
spices('spices'),
other('other');
const PantryCategory(this.persistenceKey);
/// Stable string persisted in the database; never changes across renames.
final String persistenceKey;
/// Human-readable label for grouping headers and pickers.
String get displayName {
switch (this) {
case PantryCategory.produce:
return 'Produce';
case PantryCategory.meatSeafood:
return 'Meat & Seafood';
case PantryCategory.dairy:
return 'Dairy & Eggs';
case PantryCategory.dryGoods:
return 'Dry Goods';
case PantryCategory.spices:
return 'Spices & Seasonings';
case PantryCategory.other:
return 'Other';
}
}
/// Parses a persisted key; unknown/legacy values fall back to
/// [PantryCategory.other] so legacy rows never break the pantry list.
static PantryCategory parse(String? raw) {
for (final category in values) {
if (category.persistenceKey == raw) return category;
}
return PantryCategory.other;
}
/// Best-effort aisle guess used when a checked-off shopping item is
/// transferred into the pantry without a user-selected category. Rules are
/// evaluated in order, so more specific families (meat, dairy) win over
/// broad ones (dry goods).
static PantryCategory infer(String name) {
final lower = name.toLowerCase();
if (lower.isEmpty) return PantryCategory.other;
for (final rule in _inferenceRules) {
if (rule.keywords.any(lower.contains)) return rule.category;
}
return PantryCategory.other;
}
}
class _CategoryRule {
const _CategoryRule(this.category, this.keywords);
final PantryCategory category;
final List<String> keywords;
}
const List<_CategoryRule> _inferenceRules = [
_CategoryRule(PantryCategory.meatSeafood, [
'chicken', 'beef', 'pork', 'turkey', 'lamb', 'steak', 'bacon', 'sausage',
'salmon', 'tuna', 'shrimp', 'prawn', 'fish', 'crab', 'lobster', 'scallop',
'deli meat', 'ground meat',
]),
_CategoryRule(PantryCategory.dairy, [
'yogurt', 'yoghurt', 'milk', 'cheese', 'butter', 'cream', 'egg',
'kefir', 'ricotta', 'mozzarella',
]),
_CategoryRule(PantryCategory.produce, [
'avocado', 'tomato', 'onion', 'garlic', 'lettuce', 'spinach', 'kale',
'broccoli', 'carrot', 'potato', 'lemon', 'lime', 'apple', 'banana',
'berry', 'grape', 'cucumber', 'mushroom', 'pepper', 'zucchini',
'celery', 'basil', 'cilantro', 'ginger', 'salad', 'fruit',
]),
_CategoryRule(PantryCategory.spices, [
'salt', 'cumin', 'paprika', 'oregano', 'thyme', 'rosemary', 'cinnamon',
'turmeric', 'saffron', 'seasoning', 'spice', 'cloves',
]),
_CategoryRule(PantryCategory.dryGoods, [
'flour', 'sugar', 'rice', 'pasta', 'noodle', 'bread', 'oat', 'lentil',
'bean', 'cereal', 'quinoa', 'sauce', 'vinegar', 'cocoa', 'coffee',
'honey', 'syrup', 'broth', 'stock',
]),
];

View File

@ -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})';
}

View File

@ -0,0 +1,62 @@
/// One row of the shopping checklist.
///
/// Pure Dart (no framework imports). Rows created by the delta engine carry
/// [isAutoGenerated] = `true` plus the contributing [sourceRecipeTitles];
/// manual rows are user-owned and are never rewritten by meal-plan syncs.
class ShoppingListItem {
const ShoppingListItem({
required this.id,
required this.name,
required this.requiredQuantity,
required this.unit,
this.isPurchased = false,
this.isAutoGenerated = false,
this.sourceRecipeTitles = const [],
});
/// UUID primary key assigned when the row is first created.
final String id;
/// Ingredient name as displayed; matching against the pantry normalizes
/// both sides with `canonicalIngredientName`, so casing is cosmetic.
final String name;
/// Amount still needed, expressed in [unit].
final double requiredQuantity;
/// Canonical unit key (e.g. `g`, `count`).
final String unit;
/// Whether the item has been checked off the list.
final bool isPurchased;
/// `true` for rows owned by the delta engine (recomputed on every sync).
final bool isAutoGenerated;
/// Recipe titles that contributed to the deficit (auto rows only).
final List<String> sourceRecipeTitles;
ShoppingListItem copyWith({
String? id,
String? name,
double? requiredQuantity,
String? unit,
bool? isPurchased,
bool? isAutoGenerated,
List<String>? sourceRecipeTitles,
}) =>
ShoppingListItem(
id: id ?? this.id,
name: name ?? this.name,
requiredQuantity: requiredQuantity ?? this.requiredQuantity,
unit: unit ?? this.unit,
isPurchased: isPurchased ?? this.isPurchased,
isAutoGenerated: isAutoGenerated ?? this.isAutoGenerated,
sourceRecipeTitles: sourceRecipeTitles ?? this.sourceRecipeTitles,
);
@override
String toString() =>
'ShoppingListItem($id, "$name", $requiredQuantity $unit, '
'purchased: $isPurchased, auto: $isAutoGenerated)';
}

View File

@ -0,0 +1,40 @@
import '../entities/pantry_item.dart';
import '../entities/shopping_list_item.dart';
/// Persistence contract for the pantry bounded context: on-hand inventory
/// plus the shopping checklist.
///
/// The presentation and domain layers depend only on this interface; the
/// drift-backed implementation lives in `data/repositories/` so widgets,
/// controllers, and use cases remain testable with in-memory fakes.
abstract class IPantryRepository {
/// Emits pantry stock (alphabetical) whenever it changes.
Stream<List<PantryItem>> watchItems();
/// One-shot snapshot of pantry stock (alphabetical).
Future<List<PantryItem>> getItems();
/// The inventory row with [id], or `null` when absent.
Future<PantryItem?> getItem(String id);
/// Inserts or updates [item] (keyed by `PantryItem.id`).
Future<PantryItem> saveItem(PantryItem item);
/// Removes the inventory row with [id], if present.
Future<void> deleteItem(String id);
/// Emits the shopping checklist (unchecked rows first) whenever it changes.
Stream<List<ShoppingListItem>> watchShoppingList();
/// One-shot snapshot of the shopping checklist (unchecked first).
Future<List<ShoppingListItem>> getShoppingList();
/// The checklist row with [id], or `null` when absent.
Future<ShoppingListItem?> getShoppingListItem(String id);
/// Inserts or updates [item] (keyed by `ShoppingListItem.id`).
Future<ShoppingListItem> saveShoppingListItem(ShoppingListItem item);
/// Removes the checklist row with [id], if present.
Future<void> deleteShoppingListItem(String id);
}

View File

@ -0,0 +1,293 @@
import '../../../../core/utils/uuid.dart';
import '../../../meal_planner/domain/entities/meal_plan_slot.dart';
import '../../../meal_planner/domain/entities/recipe.dart';
import '../entities/pantry_item.dart';
import '../entities/shopping_list_item.dart';
import '../repositories/pantry_repository.dart';
import '../value_objects/ingredient_name.dart';
import '../value_objects/measurement_units.dart';
/// Outcome of one delta-engine run, surfaced by the sync banner.
class ShoppingDeltaResult {
const ShoppingDeltaResult({
required this.generatedItems,
required this.updatedItems,
required this.removedItems,
required this.preservedManualItems,
required this.satisfiedNames,
required this.coveredByManualNames,
});
/// Fresh auto-generated rows for previously uncovered deficits.
final List<ShoppingListItem> generatedItems;
/// Existing auto-generated rows refreshed with a new deficit.
final List<ShoppingListItem> updatedItems;
/// Auto-generated rows removed because their requirement vanished
/// (pantry now covers it, or a manual row owns the name).
final List<ShoppingListItem> removedItems;
/// Manual rows observed during the sync — always left untouched.
final List<ShoppingListItem> preservedManualItems;
/// Canonical ingredient names fully covered by current pantry stock.
final List<String> satisfiedNames;
/// Canonical ingredient names skipped because a manual row exists.
final List<String> coveredByManualNames;
int get addedCount => generatedItems.length;
int get updatedCount => updatedItems.length;
int get clearedCount => removedItems.length;
/// Human-readable summary for the shopping-list sync banner.
String get summary {
final parts = <String>[
if (generatedItems.isNotEmpty) '$addedCount added',
if (updatedItems.isNotEmpty) '$updatedCount updated',
if (removedItems.isNotEmpty) '$clearedCount cleared',
];
if (parts.isEmpty) {
return "Your pantry already covers this week's meal plan.";
}
return 'Sync complete: ${parts.join(', ')}.';
}
}
/// Accumulated requirement for one canonical ingredient name across the
/// active meal-plan window.
class _IngredientRequirement {
UnitDimension? dimension;
/// Requirement summed in the dimension's base unit (g / ml / each).
double baseTotal = 0;
/// Per-unit base totals used to pick the dominant display unit.
final Map<String, double> baseByUnit = <String, double>{};
/// Deduplicated recipe titles contributing to this requirement.
final List<String> sourceTitles = <String>[];
}
/// Smart Pantry Delta Generator (architecture §7).
///
/// Aggregates the ingredient manifests of the active 7-day meal plan by
/// canonical ingredient name, normalizes units onto their dimension's base
/// scale, and applies
///
/// ```
/// deficit = totalRequired − currentPantryQuantity
/// ```
///
/// per ingredient. Deficits above zero become/refresh `isAutoGenerated`
/// shopping rows (logging the contributing recipes), rows whose requirement
/// disappeared are cleared, and **manual rows are never modified or removed**.
class CalculateShoppingDelta {
const CalculateShoppingDelta(this._repository);
/// Numerical floor for "has a deficit" comparisons in base units.
static const double _epsilon = 1e-9;
/// Maximum persisted name/unit lengths (mirrors the drift column bounds).
static const int _maxNameLength = 120;
static const int _maxUnitLength = 16;
final IPantryRepository _repository;
/// Runs one sync pass over [slots] (the active 7-day window) resolved
/// against their [recipes] ingredient manifests.
Future<ShoppingDeltaResult> call({
required List<MealPlanSlot> slots,
required List<Recipe> recipes,
}) async {
final pantry = await _repository.getItems();
final existing = await _repository.getShoppingList();
final requirements = _aggregateRequirements(slots, recipes);
// Index pantry stock by canonical name.
final stockByName = <String, List<PantryItem>>{};
for (final item in pantry) {
stockByName
.putIfAbsent(canonicalIngredientName(item.name), () => <PantryItem>[])
.add(item);
}
// Split the current list: manual rows own their name outright; auto rows
// are engine-owned and may be rewritten or removed.
final manualByName = <String, ShoppingListItem>{};
final autoByName = <String, ShoppingListItem>{};
for (final item in existing) {
final key = canonicalIngredientName(item.name);
if (item.isAutoGenerated) {
autoByName.putIfAbsent(key, () => item);
} else {
manualByName.putIfAbsent(key, () => item);
}
}
final generated = <ShoppingListItem>[];
final updated = <ShoppingListItem>[];
final toSave = <ShoppingListItem>[];
final satisfied = <String>[];
final coveredByManual = <String>[];
for (final entry in requirements.entries) {
final name = entry.key;
final requirement = entry.value;
final displayUnit = _preferredDisplayUnit(requirement);
var availableBase = 0.0;
final stocked = stockByName[name];
if (stocked != null) {
for (final item in stocked) {
final stockUnit = canonicalUnit(item.unit);
// Stock measured in an incompatible dimension cannot offset the
// requirement (grams do not cancel against item counts).
if (unitDimension(stockUnit) != requirement.dimension) continue;
availableBase += unitsToBase(item.quantityOnHand, stockUnit);
}
}
final deficitBase = requirement.baseTotal - availableBase;
final deficit = roundTo2(unitsFromBase(deficitBase, displayUnit));
if (deficitBase <= _epsilon || deficit <= 0) {
satisfied.add(name);
continue;
}
// Manual rows win their name: never rewritten, never duplicated.
if (manualByName.containsKey(name)) {
coveredByManual.add(name);
continue;
}
final existingAuto = autoByName[name];
// Keep the checked-off state only while the requirement is unchanged;
// a changed deficit means there is (new) shopping to do.
final keepChecked = existingAuto != null &&
existingAuto.isPurchased &&
existingAuto.requiredQuantity == deficit &&
existingAuto.unit == displayUnit;
final item = ShoppingListItem(
id: existingAuto?.id ?? generateUuid(),
name: _clampName(name),
requiredQuantity: deficit,
unit: _clampUnit(displayUnit),
isPurchased: keepChecked,
isAutoGenerated: true,
sourceRecipeTitles: List<String>.unmodifiable(requirement.sourceTitles),
);
toSave.add(item);
if (existingAuto == null) {
generated.add(item);
} else {
updated.add(item);
}
}
// Engine-owned rows whose requirement vanished (satisfied by stock or
// superseded by a manual row) are removed; manual rows never are.
final survivingIds = {for (final item in toSave) item.id};
final toRemove = <ShoppingListItem>[
for (final item in existing)
if (item.isAutoGenerated && !survivingIds.contains(item.id)) item,
];
for (final item in toRemove) {
await _repository.deleteShoppingListItem(item.id);
}
for (final item in toSave) {
await _repository.saveShoppingListItem(item);
}
return ShoppingDeltaResult(
generatedItems: generated,
updatedItems: updated,
removedItems: toRemove,
preservedManualItems: [
for (final item in existing)
if (!item.isAutoGenerated) item,
],
satisfiedNames: satisfied,
coveredByManualNames: coveredByManual,
);
}
/// Sums every resolvable recipe ingredient across the window, keyed by
/// canonical name. Slots are ordered by date (then id) so aggregations —
/// and the dominant display unit they imply — are deterministic.
Map<String, _IngredientRequirement> _aggregateRequirements(
List<MealPlanSlot> slots,
List<Recipe> recipes,
) {
final recipesById = <String, Recipe>{
for (final recipe in recipes) recipe.id: recipe,
};
final orderedSlots = List<MealPlanSlot>.of(slots)
..sort((a, b) {
final byDate = a.date.compareTo(b.date);
return byDate != 0 ? byDate : a.id.compareTo(b.id);
});
final requirements = <String, _IngredientRequirement>{};
for (final slot in orderedSlots) {
final recipeId = slot.recipeId;
if (recipeId == null) continue;
final recipe = recipesById[recipeId];
if (recipe == null) continue;
for (final ingredient in recipe.ingredients) {
final rawName = ingredient.parsedItemName.isNotEmpty
? ingredient.parsedItemName
: ingredient.rawText;
final name = canonicalIngredientName(rawName);
if (name.isEmpty) continue;
final quantity = ingredient.quantity;
// Unquantified lines ("salt to taste") cannot produce a delta.
if (quantity == null || quantity <= 0) continue;
final unit = canonicalUnit(ingredient.unit);
final dimension = unitDimension(unit);
final requirement =
requirements.putIfAbsent(name, _IngredientRequirement.new);
requirement.dimension ??= dimension;
// Guard against pathological same-name/different-dimension mixes.
if (requirement.dimension != dimension) continue;
final base = unitsToBase(quantity, unit);
requirement.baseTotal += base;
requirement.baseByUnit[unit] =
(requirement.baseByUnit[unit] ?? 0) + base;
final title = recipe.title.trim();
if (title.isNotEmpty && !requirement.sourceTitles.contains(title)) {
requirement.sourceTitles.add(title);
}
}
}
return requirements;
}
/// The unit that contributed the most base quantity (ties resolve to the
/// first-seen unit), so a plan written mostly in `lb` reports in `lb`.
String _preferredDisplayUnit(_IngredientRequirement requirement) {
var bestUnit = 'count';
var bestTotal = -1.0;
for (final entry in requirement.baseByUnit.entries) {
if (entry.value > bestTotal) {
bestTotal = entry.value;
bestUnit = entry.key;
}
}
return bestUnit;
}
String _clampName(String name) =>
name.length <= _maxNameLength ? name : name.substring(0, _maxNameLength);
String _clampUnit(String unit) =>
unit.length <= _maxUnitLength ? unit : 'count';
}

View File

@ -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(' ');
}

View File

@ -0,0 +1,115 @@
import 'ingredient_name.dart';
/// Physical dimension a unit belongs to. Quantities are only summed (and
/// subtracted from pantry stock) within the same dimension — grams never
/// cancel against millilitres or item counts.
enum UnitDimension { mass, volume, count }
/// Units expressed in grams at the base scale.
const Set<String> _massUnits = {'mg', 'g', 'kg', 'oz', 'lb'};
/// Units expressed in millilitres at the base scale.
const Set<String> _volumeUnits = {'ml', 'l', 'tsp', 'tbsp', 'cup', 'floz'};
/// Synonyms applied after lowercasing + singularizing a raw unit token
/// (`grams` → `gram` → `g`, `lbs` → `lb`, `pieces` → `count`).
const Map<String, String> _unitSynonyms = {
'milligram': 'mg',
'gram': 'g',
'kilogram': 'kg',
'ounce': 'oz',
'pound': 'lb',
'milliliter': 'ml',
'liter': 'l',
'teaspoon': 'tsp',
'tablespoon': 'tbsp',
'fluidounce': 'floz',
'piece': 'count',
'unit': 'count',
'each': 'count',
'item': 'count',
};
/// Canonical unit spellings offered by the pantry/shopping dialogs.
const List<String> pantryUnitOptions = [
'count',
'g',
'kg',
'oz',
'lb',
'ml',
'l',
'tsp',
'tbsp',
'cup',
];
/// Canonicalizes a raw unit token (recipe parser output, dialog selection, or
/// persisted value). `null`/empty defaults to the generic `count`.
String canonicalUnit(String? raw) {
if (raw == null) return 'count';
final trimmed = raw.trim().toLowerCase();
if (trimmed.isEmpty) return 'count';
final singular = singularizeWord(trimmed);
return _unitSynonyms[singular] ?? singular;
}
/// Dimension [canonical] belongs to (see [UnitDimension]).
UnitDimension unitDimension(String canonical) {
if (_massUnits.contains(canonical)) return UnitDimension.mass;
if (_volumeUnits.contains(canonical)) return UnitDimension.volume;
return UnitDimension.count;
}
/// Multiplicative factor that converts one [canonical] unit into the base
/// unit of its dimension (g / ml / each).
double _factorToBase(String canonical) {
switch (canonical) {
case 'mg':
return 0.001;
case 'kg':
return 1000;
case 'oz':
return 28.349523125;
case 'lb':
return 453.59237;
case 'l':
return 1000;
case 'floz':
return 29.5735295625;
case 'tsp':
return 4.92892159375;
case 'tbsp':
return 14.78676478125;
case 'cup':
return 236.5882365;
case 'dozen':
return 12;
default:
// `g`, `ml`, `count` and custom count-style units are their own base.
return 1;
}
}
/// Converts [quantity] of [canonical] into the base unit of its dimension
/// (grams / millilitres / each), enabling cross-unit aggregation such as
/// `0.5 lb + 226.8 g`.
double unitsToBase(double quantity, String canonical) =>
quantity * _factorToBase(canonical);
/// Inverse of [unitsToBase]: expresses [baseQuantity] in [canonical] units.
double unitsFromBase(double baseQuantity, String canonical) {
final factor = _factorToBase(canonical);
return factor == 0 ? baseQuantity : baseQuantity / factor;
}
/// Rounds derived quantities to 2 decimals for stable storage and display.
double roundTo2(double value) => (value * 100).roundToDouble() / 100;
/// Compact display formatting for quantities: up to 2 decimals with trailing
/// zeros trimmed (e.g. `500`, `0.75`, `1.5`).
String formatQuantityForDisplay(double value) {
final rounded = roundTo2(value);
if (rounded == rounded.roundToDouble()) return rounded.round().toString();
return rounded.toString();
}

View File

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

View File

@ -0,0 +1,299 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/date_time_utils.dart';
import '../../../../core/utils/uuid.dart';
import '../../../meal_planner/presentation/controllers/meal_plan_calendar_controller.dart';
import '../../../meal_planner/presentation/controllers/recipe_import_controller.dart';
import '../../domain/entities/pantry_category.dart';
import '../../domain/entities/pantry_item.dart';
import '../../domain/entities/shopping_list_item.dart';
import '../../domain/usecases/calculate_shopping_delta.dart';
import '../../domain/value_objects/ingredient_name.dart';
import '../../domain/value_objects/measurement_units.dart';
import 'pantry_inventory_controller.dart';
/// Application-wide delta-engine use case, wired to the pantry repository.
///
/// Overridable in tests with a fake repository.
final calculateShoppingDeltaProvider = Provider<CalculateShoppingDelta>(
(ref) => CalculateShoppingDelta(ref.watch(pantryRepositoryProvider)),
);
/// Checklist view filter.
enum ShoppingListFilter { all, toBuy, checked }
/// Immutable snapshot of the shopping-checklist state.
class ShoppingListState {
const ShoppingListState({
this.items = const [],
this.filter = ShoppingListFilter.all,
this.isSyncing = false,
this.hasLoaded = false,
this.statusMessage,
this.errorMessage,
});
/// Sentinel allowing `copyWith` to explicitly reset a field to `null`.
static const Object _keep = Object();
/// Checklist rows (unchecked first, kept live by the watch stream).
final List<ShoppingListItem> items;
/// Active checklist filter.
final ShoppingListFilter filter;
/// `true` while the delta engine runs against the meal plan.
final bool isSyncing;
/// `true` once the first watch emission arrived.
final bool hasLoaded;
/// Last successful sync summary (e.g. `Sync complete: 2 added, 1 cleared.`).
final String? statusMessage;
/// Last sync/persist failure message.
final String? errorMessage;
/// Rows for the active filter.
List<ShoppingListItem> get visibleItems => switch (filter) {
ShoppingListFilter.all => items,
ShoppingListFilter.toBuy => [
for (final item in items)
if (!item.isPurchased) item,
],
ShoppingListFilter.checked => [
for (final item in items)
if (item.isPurchased) item,
],
};
/// Rows still to buy (drives the tab badge).
int get remainingCount =>
items.where((item) => !item.isPurchased).length;
ShoppingListState copyWith({
List<ShoppingListItem>? items,
ShoppingListFilter? filter,
bool? isSyncing,
bool? hasLoaded,
Object? statusMessage = _keep,
Object? errorMessage = _keep,
}) =>
ShoppingListState(
items: items ?? this.items,
filter: filter ?? this.filter,
isSyncing: isSyncing ?? this.isSyncing,
hasLoaded: hasLoaded ?? this.hasLoaded,
statusMessage: identical(statusMessage, _keep)
? this.statusMessage
: statusMessage as String?,
errorMessage: identical(errorMessage, _keep)
? this.errorMessage
: errorMessage as String?,
);
}
/// Orchestrates the shopping checklist: manual additions, check-off with an
/// optional pantry-stock transfer, filtering, and the meal-plan delta sync.
class ShoppingListController extends Notifier<ShoppingListState> {
@override
ShoppingListState build() {
final subscription = ref
.read(pantryRepositoryProvider)
.watchShoppingList()
.listen(
(items) => state = state.copyWith(items: items, hasLoaded: true),
onError: (Object error, StackTrace stackTrace) {
debugPrintStack(
stackTrace: stackTrace,
label: 'shopping list watch failed',
);
state = state.copyWith(errorMessage: 'Could not load the list.');
},
);
ref.onDispose(subscription.cancel);
return const ShoppingListState();
}
/// Adds a manual checklist row (never touched by delta syncs).
Future<bool> addItem({
required String name,
required double quantity,
required String unit,
}) async {
final trimmed = name.trim();
if (trimmed.isEmpty || quantity <= 0) {
state = state.copyWith(
errorMessage: 'Enter a name and a quantity above zero.',
);
return false;
}
try {
await ref.read(pantryRepositoryProvider).saveShoppingListItem(
ShoppingListItem(
id: generateUuid(),
name: trimmed,
requiredQuantity: quantity,
unit: unit,
),
);
state = state.copyWith(errorMessage: null, statusMessage: null);
return true;
} catch (error, stackTrace) {
debugPrintStack(
stackTrace: stackTrace,
label: 'shopping item save failed',
);
state = state.copyWith(errorMessage: 'Could not add the item.');
return false;
}
}
/// Flips the checked-off state of [item].
Future<void> togglePurchased(ShoppingListItem item) =>
_save(item.copyWith(isPurchased: !item.isPurchased));
/// Marks [item] checked. With [addToPantry] the purchased quantity is first
/// merged into pantry stock — converting units when both sides share a
/// dimension, creating a new (aisle-inferred) row otherwise.
Future<bool> completeItem(
ShoppingListItem item, {
required bool addToPantry,
}) async {
try {
if (addToPantry) await _transferToPantry(item);
await ref.read(pantryRepositoryProvider).saveShoppingListItem(
item.copyWith(isPurchased: true),
);
return true;
} catch (error, stackTrace) {
debugPrintStack(
stackTrace: stackTrace,
label: 'shopping check-off failed',
);
state = state.copyWith(
errorMessage: 'Could not update the shopping list.',
);
return false;
}
}
Future<void> _save(ShoppingListItem item) async {
try {
await ref.read(pantryRepositoryProvider).saveShoppingListItem(item);
} catch (error, stackTrace) {
debugPrintStack(
stackTrace: stackTrace,
label: 'shopping item save failed',
);
state = state.copyWith(errorMessage: 'Could not update the list.');
}
}
Future<void> _transferToPantry(ShoppingListItem item) async {
final pantry = ref.read(pantryRepositoryProvider);
final key = canonicalIngredientName(item.name);
final stock = await pantry.getItems();
PantryItem? match;
for (final candidate in stock) {
if (canonicalIngredientName(candidate.name) == key) {
match = candidate;
break;
}
}
final itemUnit = canonicalUnit(item.unit);
if (match != null &&
unitDimension(canonicalUnit(match.unit)) == unitDimension(itemUnit)) {
// Merge into the existing row, converting into its unit when needed.
final converted = unitsFromBase(
unitsToBase(item.requiredQuantity, itemUnit),
canonicalUnit(match.unit),
);
await pantry.saveItem(
match.copyWith(
quantityOnHand: match.quantityOnHand + converted,
lastUpdated: DateTime.now(),
),
);
return;
}
await pantry.saveItem(
PantryItem(
id: generateUuid(),
// Canonical lowercase key so future delta syncs match this row.
name: key.isEmpty ? item.name.trim() : key,
quantityOnHand: item.requiredQuantity,
unit: itemUnit,
category: PantryCategory.infer(item.name),
lastUpdated: DateTime.now(),
),
);
}
/// Removes the checklist row with [id] (swipe-to-delete).
Future<void> removeItem(String id) async {
try {
await ref.read(pantryRepositoryProvider).deleteShoppingListItem(id);
} catch (error, stackTrace) {
debugPrintStack(
stackTrace: stackTrace,
label: 'shopping item delete failed',
);
state = state.copyWith(errorMessage: 'Could not remove the item.');
}
}
/// Switches the active checklist filter (All / To Buy / Checked).
void setFilter(ShoppingListFilter filter) =>
state = state.copyWith(filter: filter);
/// Clears the last status/error messages.
void clearMessages() =>
state = state.copyWith(statusMessage: null, errorMessage: null);
/// Runs the delta engine against the active Monday–Sunday meal-plan window
/// (or [windowStart]) and returns the human-readable summary — or `null`
/// when the sync failed (see `ShoppingListState.errorMessage`).
Future<String?> syncWithMealPlan({DateTime? windowStart}) async {
if (state.isSyncing) return null;
state = state.copyWith(
isSyncing: true,
statusMessage: null,
errorMessage: null,
);
try {
final start = windowStart ?? startOfWeek(DateTime.now());
final slots = await ref.read(mealPlanRepositoryProvider).getSlots(
from: start,
to: start.add(const Duration(days: 7)),
);
final recipes = await ref.read(recipeRepositoryProvider).getRecipes();
final result = await ref.read(calculateShoppingDeltaProvider)(
slots: slots,
recipes: recipes,
);
state = state.copyWith(
isSyncing: false,
statusMessage: result.summary,
);
return result.summary;
} catch (error, stackTrace) {
debugPrintStack(stackTrace: stackTrace, label: 'pantry delta sync failed');
state = state.copyWith(
isSyncing: false,
errorMessage: 'Could not sync with the meal plan.',
);
return null;
}
}
}
/// Exposes the [ShoppingListController] to the widget tree.
final shoppingListControllerProvider =
NotifierProvider<ShoppingListController, ShoppingListState>(
ShoppingListController.new,
);

View File

@ -1,51 +1,683 @@
import 'package:flutter/material.dart'; 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}); const PantryScreen({super.key});
@override
ConsumerState<PantryScreen> createState() => _PantryScreenState();
}
class _PantryScreenState extends ConsumerState<PantryScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabController =
TabController(length: 2, vsync: this);
/// Mirrors the controller's index so the FAB rebuilds exactly when the
/// active tab changes (not on every animation tick).
int _tabIndex = 0;
@override
void initState() {
super.initState();
_tabController.addListener(_handleTabChange);
}
void _handleTabChange() {
if (_tabIndex == _tabController.index) return;
setState(() => _tabIndex = _tabController.index);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final shoppingState = ref.watch(shoppingListControllerProvider);
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Pantry & Shopping Delta')), appBar: AppBar(
body: ListView( title: const Text('Pantry & Shopping Delta'),
padding: const EdgeInsets.all(16.0), bottom: TabBar(
children: [ controller: _tabController,
Row( tabs: [
mainAxisAlignment: MainAxisAlignment.spaceBetween, const Tab(
children: [ icon: Icon(Icons.kitchen_outlined),
const Text( text: 'Inventory',
'Inventory', ),
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), Tab(
icon: Badge(
isLabelVisible: shoppingState.remainingCount > 0,
label: Text('${shoppingState.remainingCount}'),
child: const Icon(Icons.shopping_basket_outlined),
), ),
FilledButton.tonalIcon( text: 'Shopping List',
onPressed: () {}, ),
icon: const Icon(Icons.add), ],
label: const Text('Add Item'), ),
),
body: TabBarView(
controller: _tabController,
children: [
_buildInventoryTab(),
_buildShoppingTab(),
],
),
floatingActionButton: _tabIndex == 0
? FloatingActionButton.extended(
onPressed: _showAddPantryItemDialog,
icon: const Icon(Icons.add),
label: const Text('Add Pantry Item'),
)
: FloatingActionButton.extended(
onPressed: _showAddShoppingItemDialog,
icon: const Icon(Icons.add_shopping_cart),
label: const Text('Add Item'),
),
);
}
Future<void> _showAddPantryItemDialog() async {
final saved = await showAddPantryItemDialog(context);
if (saved != true || !mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Added to your pantry.')),
);
}
Future<void> _showAddShoppingItemDialog() async {
final saved = await showAddShoppingItemDialog(context);
if (saved != true || !mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Added to your shopping list.')),
);
}
// ---------------------------------------------------------------------------
// Inventory tab
// ---------------------------------------------------------------------------
Widget _buildInventoryTab() {
final state = ref.watch(pantryInventoryControllerProvider);
if (!state.hasLoaded && state.items.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: SegmentedButton<PantrySortMode>(
segments: const [
ButtonSegment(
value: PantrySortMode.category,
icon: Icon(Icons.category_outlined),
label: Text('Aisles'),
),
ButtonSegment(
value: PantrySortMode.expiration,
icon: Icon(Icons.schedule),
label: Text('Expiration'),
),
],
selected: {state.sortMode},
onSelectionChanged: (selection) => ref
.read(pantryInventoryControllerProvider.notifier)
.setSortMode(selection.first),
),
),
if (state.errorMessage != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(
state.errorMessage!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
Expanded(
child: state.items.isEmpty
? _buildEmptyInventory()
: ListView(
padding: const EdgeInsets.only(bottom: 88),
children: state.sortMode == PantrySortMode.category
? _buildCategorySections(state)
: [
for (final item in state.byExpiration)
_buildInventoryTile(item),
],
),
),
],
);
}
/// Aisle header + tiles for every non-empty category.
List<Widget> _buildCategorySections(PantryInventoryState state) {
final sections = <Widget>[];
for (final entry in state.groupedByCategory.entries) {
sections.add(
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
children: [
Icon(
_categoryIcon(entry.key),
size: 16,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'${entry.key.displayName} · ${entry.value.length}',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
), ),
], ],
), ),
const SizedBox(height: 8), ),
const ListTile( );
leading: Icon(Icons.check_box_outline_blank), for (final item in entry.value) {
title: Text('Boneless Chicken Breast'), sections.add(_buildInventoryTile(item));
subtitle: Text('In stock: 300g'), }
}
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), onDismissed: (_) => notifier.deleteItem(item.id),
title: Text('Plain Greek Yogurt'), child: Card(
subtitle: Text('In stock: 500g'), margin: EdgeInsets.zero,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
),
), ),
const Divider(height: 32), child: Padding(
const Text( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
'Shopping List (Auto-Generated Delta)', child: Row(
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), children: [
CircleAvatar(
radius: 16,
child: Icon(_categoryIcon(item.category), size: 16),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall,
),
if (item.expirationDate != null) ...[
const SizedBox(height: 2),
Wrap(
spacing: 6,
runSpacing: 2,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
'Expires ${formatMonthDay(item.expirationDate!)}',
style: Theme.of(context).textTheme.bodySmall,
),
_buildExpirationBadge(item),
],
),
],
],
),
),
IconButton(
visualDensity: VisualDensity.compact,
tooltip: 'Remove one',
icon: const Icon(Icons.remove_circle_outline),
onPressed: () => notifier.adjustQuantity(item, -step),
),
Text(
'${formatQuantityForDisplay(item.quantityOnHand)} ${item.unit}',
style: Theme.of(context).textTheme.labelMedium,
),
IconButton(
visualDensity: VisualDensity.compact,
tooltip: 'Add one',
icon: const Icon(Icons.add_circle_outline),
onPressed: () => notifier.adjustQuantity(item, step),
),
],
),
), ),
const SizedBox(height: 8), ),
const CheckboxListTile( ),
value: false, );
onChanged: null, }
title: Text('Chicken Breast (Need +500g for Mon Plan)'),
/// Compact warning chip for expired / soon-to-expire stock.
Widget _buildExpirationBadge(PantryItem item) {
final status = item.expirationStatus();
if (status == ExpirationStatus.none || status == ExpirationStatus.fresh) {
return const SizedBox.shrink();
}
final scheme = Theme.of(context).colorScheme;
final Color background;
final Color foreground;
if (status == ExpirationStatus.expired) {
background = scheme.errorContainer;
foreground = scheme.onErrorContainer;
} else {
background = scheme.tertiaryContainer;
foreground = scheme.onTertiaryContainer;
}
final days = item.daysUntilExpiration() ?? 0;
final String label;
if (status == ExpirationStatus.expired) {
label = 'Expired';
} else if (days <= 0) {
label = 'Expires today';
} else if (days == 1) {
label = 'Expires tomorrow';
} else {
label = '$days days left';
}
return DecoratedBox(
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(6),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
child: Text(
label,
style: TextStyle(
color: foreground,
fontSize: 11,
fontWeight: FontWeight.w600,
), ),
),
),
);
}
Widget _buildEmptyInventory() => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.kitchen_outlined,
size: 48,
color: Theme.of(context).colorScheme.outline,
),
const SizedBox(height: 12),
Text(
'Your pantry is empty',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'Tap "Add Pantry Item" to stock up.',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
/// Stock step for the quick +/- counters, chosen per unit family.
double _quickStepFor(String rawUnit) {
switch (canonicalUnit(rawUnit)) {
case 'g':
case 'ml':
return 50;
case 'kg':
case 'l':
return 0.5;
case 'lb':
return 0.25;
default:
return 1;
}
}
IconData _categoryIcon(PantryCategory category) {
switch (category) {
case PantryCategory.produce:
return Icons.local_florist_outlined;
case PantryCategory.meatSeafood:
return Icons.set_meal_outlined;
case PantryCategory.dairy:
return Icons.icecream_outlined;
case PantryCategory.dryGoods:
return Icons.grain_outlined;
case PantryCategory.spices:
return Icons.spa_outlined;
case PantryCategory.other:
return Icons.kitchen_outlined;
}
}
// ---------------------------------------------------------------------------
// Shopping list tab
// ---------------------------------------------------------------------------
Widget _buildShoppingTab() {
final state = ref.watch(shoppingListControllerProvider);
final controller = ref.read(shoppingListControllerProvider.notifier);
if (!state.hasLoaded && state.items.isEmpty && !state.isSyncing) {
return const Center(child: CircularProgressIndicator());
}
return Column(
children: [
_buildSyncBanner(state, controller),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: SegmentedButton<ShoppingListFilter>(
segments: const [
ButtonSegment(
value: ShoppingListFilter.all,
label: Text('All'),
),
ButtonSegment(
value: ShoppingListFilter.toBuy,
label: Text('To Buy'),
),
ButtonSegment(
value: ShoppingListFilter.checked,
label: Text('Checked'),
),
],
selected: {state.filter},
onSelectionChanged: (selection) =>
controller.setFilter(selection.first),
),
),
Expanded(
child: state.visibleItems.isEmpty
? _buildEmptyShoppingList(state)
: ListView(
padding: const EdgeInsets.only(bottom: 88),
children: [
for (final item in state.visibleItems)
_buildShoppingTile(item, controller),
],
),
),
],
);
}
/// Prominent banner that triggers the meal-plan delta engine.
Widget _buildSyncBanner(
ShoppingListState state,
ShoppingListController controller,
) {
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: DecoratedBox(
decoration: BoxDecoration(
color: scheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.sync_alt, color: scheme.onSecondaryContainer),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Sync with Meal Plan',
style: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(
color: scheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
Text(
"Compare this week's recipes against pantry stock.",
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: scheme.onSecondaryContainer),
),
],
),
),
const SizedBox(width: 8),
FilledButton.tonalIcon(
onPressed:
state.isSyncing ? null : () => _runSync(controller),
icon: state.isSyncing
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.play_arrow),
label: const Text('Run Sync'),
),
],
),
if (state.statusMessage != null) ...[
const SizedBox(height: 8),
Text(
state.statusMessage!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: scheme.onSecondaryContainer,
),
),
],
if (state.errorMessage != null) ...[
const SizedBox(height: 8),
Text(
state.errorMessage!,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: scheme.error),
),
],
],
),
),
),
);
}
Future<void> _runSync(ShoppingListController controller) async {
final summary = await controller.syncWithMealPlan();
if (summary == null || !mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(summary)),
);
}
Widget _buildShoppingTile(
ShoppingListItem item,
ShoppingListController controller,
) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Dismissible(
key: ValueKey('shopping-${item.id}'),
direction: DismissDirection.endToStart,
background: ColoredBox(
color: theme.colorScheme.errorContainer,
child: const Align(
alignment: Alignment.centerRight,
child: Padding(
padding: EdgeInsets.only(right: 20),
child: Icon(Icons.delete_outline),
),
),
),
onDismissed: (_) => controller.removeItem(item.id),
child: Card(
margin: EdgeInsets.zero,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: theme.colorScheme.outlineVariant),
),
child: CheckboxListTile(
value: item.isPurchased,
controlAffinity: ListTileControlAffinity.leading,
onChanged: (_) => _onShoppingItemToggled(item),
title: Text(
'${item.name} · '
'${formatQuantityForDisplay(item.requiredQuantity)} ${item.unit}',
style: item.isPurchased
? theme.textTheme.bodyLarge?.copyWith(
decoration: TextDecoration.lineThrough,
color: theme.hintColor,
)
: theme.textTheme.bodyLarge,
),
subtitle:
item.isAutoGenerated && item.sourceRecipeTitles.isNotEmpty
? Padding(
padding: const EdgeInsets.only(top: 4),
child: _buildSourceBadges(item),
)
: null,
),
),
),
);
}
/// Subtle "From: <recipe>" origin badges for auto-generated rows.
Widget _buildSourceBadges(ShoppingListItem item) {
final titles = item.sourceRecipeTitles;
return Wrap(
spacing: 6,
runSpacing: 4,
children: [
for (final title in titles.take(2)) _sourceBadge('From: $title'),
if (titles.length > 2) _sourceBadge('+${titles.length - 2} more'),
],
);
}
Widget _sourceBadge(String label) {
final scheme = Theme.of(context).colorScheme;
return DecoratedBox(
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(999),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.restaurant_menu,
size: 12,
color: scheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(fontSize: 11, color: scheme.onSurfaceVariant),
),
],
),
),
);
}
/// Check-off flow: ticking an item offers the pantry-stock transfer prompt;
/// unchecking flips the flag directly.
Future<void> _onShoppingItemToggled(ShoppingListItem item) async {
final controller = ref.read(shoppingListControllerProvider.notifier);
if (item.isPurchased) {
await controller.togglePurchased(item);
return;
}
final addToPantry = await showAddToPantryPrompt(context, item);
if (addToPantry == null || !mounted) return;
await controller.completeItem(item, addToPantry: addToPantry);
}
Widget _buildEmptyShoppingList(ShoppingListState state) {
final String message;
final String hint;
switch (state.filter) {
case ShoppingListFilter.checked:
message = 'Nothing checked off yet.';
hint = 'Tick items when you buy them.';
case ShoppingListFilter.toBuy:
message = 'Nothing left to buy.';
hint = 'Run the sync to pull in missing recipe ingredients.';
case ShoppingListFilter.all:
message = 'Your shopping list is clear.';
hint = 'Run the sync to pull in missing recipe ingredients.';
}
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.checklist,
size: 48,
color: Theme.of(context).colorScheme.outline,
),
const SizedBox(height: 12),
Text(message, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(hint, style: Theme.of(context).textTheme.bodySmall),
], ],
), ),
); );

View File

@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/date_time_utils.dart';
import '../../domain/entities/pantry_category.dart';
import '../../domain/value_objects/measurement_units.dart';
import '../controllers/pantry_inventory_controller.dart';
/// Opens the "Add Pantry Item" dialog.
///
/// Resolves to `true` when the item was persisted, `false` when saving
/// failed, and `null` when the user dismissed the dialog.
Future<bool?> showAddPantryItemDialog(BuildContext context) => showDialog<bool>(
context: context,
builder: (context) => const AddPantryItemDialog(),
);
/// Form for stocking the pantry: name, quantity, unit, grocery aisle, and an
/// optional expiration date.
class AddPantryItemDialog extends ConsumerStatefulWidget {
const AddPantryItemDialog({super.key});
@override
ConsumerState<AddPantryItemDialog> createState() =>
_AddPantryItemDialogState();
}
class _AddPantryItemDialogState extends ConsumerState<AddPantryItemDialog> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final TextEditingController _nameController = TextEditingController();
final TextEditingController _quantityController = TextEditingController();
late String _unit = pantryUnitOptions.first;
PantryCategory _category = PantryCategory.other;
DateTime? _expirationDate;
bool _isSaving = false;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Add Pantry Item'),
content: SizedBox(
width: 440,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Name',
hintText: 'e.g. chicken breast',
border: OutlineInputBorder(),
),
textCapitalization: TextCapitalization.sentences,
validator: _nameValidator,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _quantityController,
decoration: const InputDecoration(
labelText: 'Quantity',
border: OutlineInputBorder(),
),
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
validator: _quantityValidator,
),
),
const SizedBox(width: 12),
Expanded(
child: DropdownButtonFormField<String>(
initialValue: _unit,
decoration: const InputDecoration(
labelText: 'Unit',
border: OutlineInputBorder(),
),
items: [
for (final unit in pantryUnitOptions)
DropdownMenuItem(value: unit, child: Text(unit)),
],
onChanged: (unit) {
if (unit == null) return;
setState(() => _unit = unit);
},
),
),
],
),
const SizedBox(height: 16),
DropdownButtonFormField<PantryCategory>(
initialValue: _category,
decoration: const InputDecoration(
labelText: 'Aisle',
border: OutlineInputBorder(),
),
items: [
for (final category in PantryCategory.values)
DropdownMenuItem(
value: category,
child: Text(category.displayName),
),
],
onChanged: (category) {
if (category == null) return;
setState(() => _category = category);
},
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(
_expirationDate == null
? 'No expiration date'
: 'Expires ${formatMonthDay(_expirationDate!)}',
style: Theme.of(context).textTheme.bodyMedium,
),
),
if (_expirationDate != null)
IconButton(
tooltip: 'Clear date',
icon: const Icon(Icons.clear),
onPressed: () =>
setState(() => _expirationDate = null),
),
TextButton(
onPressed: _pickExpirationDate,
child: const Text('Pick Date'),
),
],
),
],
),
),
),
),
actions: [
TextButton(
onPressed: _isSaving ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton.icon(
onPressed: _isSaving ? null : _save,
icon: _isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.add),
label: const Text('Add Item'),
),
],
);
}
Future<void> _pickExpirationDate() async {
final now = DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: _expirationDate ?? now,
firstDate: DateTime(now.year - 1),
lastDate: DateTime(now.year + 5),
);
if (picked == null || !mounted) return;
setState(() => _expirationDate = picked);
}
Future<void> _save() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() => _isSaving = true);
final saved =
await ref.read(pantryInventoryControllerProvider.notifier).addItem(
name: _nameController.text,
quantityOnHand: double.parse(_quantityController.text.trim()),
unit: _unit,
category: _category,
expirationDate: _expirationDate,
);
if (!mounted) return;
Navigator.of(context).pop(saved);
}
String? _nameValidator(String? value) {
final trimmed = (value ?? '').trim();
if (trimmed.isEmpty) return 'Enter a name';
if (trimmed.length > 128) return 'Name is too long (max 128)';
return null;
}
String? _quantityValidator(String? value) {
final parsed = double.tryParse((value ?? '').trim());
if (parsed == null || parsed < 0) return 'Enter a valid quantity';
return null;
}
@override
void dispose() {
_nameController.dispose();
_quantityController.dispose();
super.dispose();
}
}

View File

@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/value_objects/measurement_units.dart';
import '../controllers/shopping_list_controller.dart';
/// Opens the manual "Add Shopping Item" dialog.
///
/// Resolves to `true` when the row was persisted, `false` when saving
/// failed, and `null` when the user dismissed the dialog.
Future<bool?> showAddShoppingItemDialog(BuildContext context) =>
showDialog<bool>(
context: context,
builder: (context) => const AddShoppingItemDialog(),
);
/// Form for adding a checklist row by hand: name, quantity, and unit.
class AddShoppingItemDialog extends ConsumerStatefulWidget {
const AddShoppingItemDialog({super.key});
@override
ConsumerState<AddShoppingItemDialog> createState() =>
_AddShoppingItemDialogState();
}
class _AddShoppingItemDialogState
extends ConsumerState<AddShoppingItemDialog> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final TextEditingController _nameController = TextEditingController();
final TextEditingController _quantityController = TextEditingController();
late String _unit = pantryUnitOptions.first;
bool _isSaving = false;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Add Shopping Item'),
content: SizedBox(
width: 440,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Name',
hintText: 'e.g. olive oil',
border: OutlineInputBorder(),
),
textCapitalization: TextCapitalization.sentences,
validator: _nameValidator,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _quantityController,
decoration: const InputDecoration(
labelText: 'Quantity',
border: OutlineInputBorder(),
),
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
validator: _quantityValidator,
),
),
const SizedBox(width: 12),
Expanded(
child: DropdownButtonFormField<String>(
initialValue: _unit,
decoration: const InputDecoration(
labelText: 'Unit',
border: OutlineInputBorder(),
),
items: [
for (final unit in pantryUnitOptions)
DropdownMenuItem(value: unit, child: Text(unit)),
],
onChanged: (unit) {
if (unit == null) return;
setState(() => _unit = unit);
},
),
),
],
),
],
),
),
),
),
actions: [
TextButton(
onPressed: _isSaving ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton.icon(
onPressed: _isSaving ? null : _save,
icon: _isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.add_shopping_cart),
label: const Text('Add Item'),
),
],
);
}
Future<void> _save() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() => _isSaving = true);
final saved =
await ref.read(shoppingListControllerProvider.notifier).addItem(
name: _nameController.text,
quantity: double.parse(_quantityController.text.trim()),
unit: _unit,
);
if (!mounted) return;
Navigator.of(context).pop(saved);
}
String? _nameValidator(String? value) {
final trimmed = (value ?? '').trim();
if (trimmed.isEmpty) return 'Enter a name';
if (trimmed.length > 128) return 'Name is too long (max 128)';
return null;
}
String? _quantityValidator(String? value) {
final parsed = double.tryParse((value ?? '').trim());
if (parsed == null || parsed <= 0) return 'Enter a quantity above zero';
return null;
}
@override
void dispose() {
_nameController.dispose();
_quantityController.dispose();
super.dispose();
}
}

View File

@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import '../../domain/entities/shopping_list_item.dart';
import '../../domain/value_objects/measurement_units.dart';
/// Prompts "Add to Pantry Inventory?" when a checklist item is ticked.
///
/// Resolves to `true` (check off AND merge into pantry stock), `false`
/// (check off only), or `null` (cancel — the item stays unchecked).
Future<bool?> showAddToPantryPrompt(
BuildContext context,
ShoppingListItem item,
) =>
showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Add to Pantry Inventory?'),
content: Text(
'Check off "${item.name}" '
'(${formatQuantityForDisplay(item.requiredQuantity)} ${item.unit}) '
'and add it to your pantry stock?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Check Off Only'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Add to Pantry'),
),
],
),
);

View File

@ -0,0 +1,387 @@
import 'package:drift/native.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:glp1_companion/core/database/app_database.dart';
import 'package:glp1_companion/core/utils/date_time_utils.dart';
import 'package:glp1_companion/core/utils/uuid.dart';
import 'package:glp1_companion/features/meal_planner/data/repositories/drift_meal_plan_repository.dart';
import 'package:glp1_companion/features/meal_planner/data/repositories/drift_recipe_repository.dart';
import 'package:glp1_companion/features/meal_planner/domain/entities/macro_profile.dart';
import 'package:glp1_companion/features/meal_planner/domain/entities/meal_plan_slot.dart';
import 'package:glp1_companion/features/meal_planner/domain/entities/recipe.dart';
import 'package:glp1_companion/features/meal_planner/domain/entities/recipe_ingredient.dart';
import 'package:glp1_companion/features/nutrition/domain/entities/meal_type.dart';
import 'package:glp1_companion/features/pantry/data/repositories/drift_pantry_repository.dart';
import 'package:glp1_companion/features/pantry/domain/entities/pantry_category.dart';
import 'package:glp1_companion/features/pantry/domain/entities/pantry_item.dart';
import 'package:glp1_companion/features/pantry/domain/entities/shopping_list_item.dart';
import 'package:glp1_companion/features/pantry/domain/usecases/calculate_shopping_delta.dart';
import 'package:glp1_companion/features/pantry/presentation/controllers/pantry_inventory_controller.dart';
import 'package:glp1_companion/features/pantry/presentation/controllers/shopping_list_controller.dart';
void main() {
late AppDatabase database;
late DriftPantryRepository repository;
late CalculateShoppingDelta deltaEngine;
late ProviderContainer container;
/// A Monday (stable fixture for deterministic slot ordering).
final weekMonday = DateTime(2026, 10, 12);
PantryItem makeStock({
String? id,
required String name,
required double quantity,
String unit = 'g',
PantryCategory category = PantryCategory.other,
}) =>
PantryItem(
id: id ?? generateUuid(),
name: name,
quantityOnHand: quantity,
unit: unit,
category: category,
lastUpdated: DateTime(2026, 10, 12, 9),
);
Recipe makeRecipe({
required String title,
required List<RecipeIngredient> ingredients,
}) =>
Recipe(
id: generateUuid(),
title: title,
sourceUrl: 'https://recipes.test/${generateUuid()}',
servings: 4,
ingredients: ingredients,
instructions: const ['Cook it.'],
macrosPerServing: const MacroProfile(
calories: 400,
proteinGrams: 40,
carbsGrams: 20,
fatGrams: 15,
),
);
MealPlanSlot makeSlot({required DateTime day, required String recipeId}) =>
MealPlanSlot(
id: generateUuid(),
date: day,
mealType: MealType.dinner,
recipeId: recipeId,
);
RecipeIngredient ingredient(
String name,
double quantity,
String? unit, {
String? rawText,
}) =>
RecipeIngredient(
rawText: rawText ?? '$quantity ${unit ?? ''} $name'.trim(),
parsedItemName: name,
quantity: quantity,
unit: unit,
);
setUp(() {
database = AppDatabase.forTesting(NativeDatabase.memory());
repository = DriftPantryRepository(database);
deltaEngine = CalculateShoppingDelta(repository);
container = ProviderContainer(
overrides: [appDatabaseProvider.overrideWithValue(database)],
);
});
tearDown(() {
container.dispose();
database.close();
});
group('CalculateShoppingDelta', () {
test('300 g on hand vs an 800 g requirement generates a +500 g entry',
() async {
await repository.saveItem(
makeStock(name: 'chicken breast', quantity: 300, unit: 'g'),
);
final recipe = makeRecipe(
title: 'Lemon Herb Chicken',
ingredients: [
ingredient(
'chicken breast',
800,
'g',
rawText: '800 g chicken breast',
),
],
);
final result = await deltaEngine(
slots: [makeSlot(day: weekMonday, recipeId: recipe.id)],
recipes: [recipe],
);
final list = await repository.getShoppingList();
expect(list, hasLength(1));
final entry = list.single;
expect(entry.name, 'chicken breast');
expect(entry.requiredQuantity, 500);
expect(entry.unit, 'g');
expect(entry.isAutoGenerated, isTrue);
expect(entry.isPurchased, isFalse);
expect(entry.sourceRecipeTitles, ['Lemon Herb Chicken']);
expect(result.addedCount, 1);
expect(result.updatedCount, 0);
expect(result.removedItems, isEmpty);
expect(result.summary, 'Sync complete: 1 added.');
});
test('sufficient stock generates no shopping entry', () async {
await repository.saveItem(
makeStock(name: 'chicken breast', quantity: 900, unit: 'g'),
);
final recipe = makeRecipe(
title: 'Lemon Herb Chicken',
ingredients: [ingredient('chicken breast', 800, 'g')],
);
final result = await deltaEngine(
slots: [makeSlot(day: weekMonday, recipeId: recipe.id)],
recipes: [recipe],
);
expect(await repository.getShoppingList(), isEmpty);
expect(result.addedCount, 0);
expect(result.satisfiedNames, ['chicken breast']);
expect(
result.summary,
"Your pantry already covers this week's meal plan.",
);
});
test('manual shopping items are preserved when the sync runs', () async {
final manual = ShoppingListItem(
id: generateUuid(),
name: 'Olive Oil',
requiredQuantity: 1,
unit: 'bottle',
);
await repository.saveShoppingListItem(manual);
await repository.saveItem(
makeStock(name: 'chicken breast', quantity: 300, unit: 'g'),
);
final recipe = makeRecipe(
title: 'Lemon Herb Chicken',
ingredients: [ingredient('chicken breast', 800, 'g')],
);
final slots = [makeSlot(day: weekMonday, recipeId: recipe.id)];
await deltaEngine(slots: slots, recipes: [recipe]);
// Re-running the sync must neither drop nor duplicate the manual row.
await deltaEngine(slots: slots, recipes: [recipe]);
final list = await repository.getShoppingList();
expect(list, hasLength(2));
final restoredManual = list.firstWhere((item) => item.id == manual.id);
expect(restoredManual.name, 'Olive Oil');
expect(restoredManual.requiredQuantity, 1);
expect(restoredManual.unit, 'bottle');
expect(restoredManual.isAutoGenerated, isFalse);
expect(restoredManual.isPurchased, isFalse);
expect(list.where((item) => item.isAutoGenerated), hasLength(1));
expect(list.where((item) => item.id == manual.id), hasLength(1));
});
test('stale auto entries are cleared once stock covers the plan', () async {
final stock = makeStock(name: 'chicken breast', quantity: 300, unit: 'g');
await repository.saveItem(stock);
final recipe = makeRecipe(
title: 'Lemon Herb Chicken',
ingredients: [ingredient('chicken breast', 800, 'g')],
);
final slots = [makeSlot(day: weekMonday, recipeId: recipe.id)];
await deltaEngine(slots: slots, recipes: [recipe]);
expect(await repository.getShoppingList(), hasLength(1));
await repository.saveItem(stock.copyWith(quantityOnHand: 900));
final result = await deltaEngine(slots: slots, recipes: [recipe]);
expect(await repository.getShoppingList(), isEmpty);
expect(result.removedItems, hasLength(1));
expect(result.clearedCount, 1);
});
test('aggregates across recipes and normalizes plurals and units',
() async {
// Plural/missing-unit lines fold onto one canonical "egg" key:
// 4 eggs + 2 egg − 2 in stock = a +4 "egg" deficit.
await repository.saveItem(
makeStock(name: 'egg', quantity: 2, unit: 'count'),
);
final breakfast = makeRecipe(
title: 'Baked Eggs',
ingredients: [ingredient('eggs', 4, null, rawText: '4 eggs')],
);
final brunch = makeRecipe(
title: 'Sunday Omelette',
ingredients: [ingredient('egg', 2, null, rawText: '2 egg')],
);
final result = await deltaEngine(
slots: [
makeSlot(day: weekMonday, recipeId: breakfast.id),
makeSlot(
day: weekMonday.add(const Duration(days: 1)),
recipeId: brunch.id,
),
],
recipes: [breakfast, brunch],
);
final eggEntry = (await repository.getShoppingList())
.singleWhere((item) => item.name == 'egg');
expect(eggEntry.requiredQuantity, 4);
expect(eggEntry.unit, 'count');
expect(eggEntry.sourceRecipeTitles, ['Baked Eggs', 'Sunday Omelette']);
expect(result.addedCount, 1);
});
test('converts lbs and oz onto one scale before computing the delta',
() async {
await repository.saveItem(
makeStock(name: 'butter', quantity: 4, unit: 'oz'),
);
final lbRecipe = makeRecipe(
title: 'Cake Batter',
ingredients: [ingredient('butter', 0.5, 'lb', rawText: '0.5 lb butter')],
);
final ozRecipe = makeRecipe(
title: 'Sauté Night',
ingredients: [ingredient('butter', 8, 'oz', rawText: '8 oz butter')],
);
await deltaEngine(
slots: [
// Earlier date wins the dominant-unit tie (lb is aggregated first).
makeSlot(day: weekMonday, recipeId: lbRecipe.id),
makeSlot(
day: weekMonday.add(const Duration(days: 1)),
recipeId: ozRecipe.id,
),
],
recipes: [lbRecipe, ozRecipe],
);
final butter = (await repository.getShoppingList())
.singleWhere((item) => item.name == 'butter');
expect(butter.unit, 'lb');
// (0.5 lb + 8 oz − 4 oz) = 0.75 lb.
expect(butter.requiredQuantity, closeTo(0.75, 0.001));
});
});
group('ShoppingListController', () {
test('checking off with pantry transfer increments stock', () async {
await repository.saveItem(
makeStock(name: 'chicken breast', quantity: 300, unit: 'g'),
);
final item = ShoppingListItem(
id: generateUuid(),
name: 'Chicken Breast',
requiredQuantity: 500,
unit: 'g',
isAutoGenerated: true,
sourceRecipeTitles: const ['Lemon Herb Chicken'],
);
await repository.saveShoppingListItem(item);
final controller =
container.read(shoppingListControllerProvider.notifier);
final ok = await controller.completeItem(item, addToPantry: true);
expect(ok, isTrue);
final stock = await repository.getItems();
expect(stock, hasLength(1));
expect(stock.single.quantityOnHand, 800);
expect((await repository.getShoppingList()).single.isPurchased, isTrue);
});
test('checking off a new item creates aisle-inferred pantry stock',
() async {
final item = ShoppingListItem(
id: generateUuid(),
name: 'Greek Yogurt',
requiredQuantity: 2,
unit: 'count',
);
await repository.saveShoppingListItem(item);
final controller =
container.read(shoppingListControllerProvider.notifier);
await controller.completeItem(item, addToPantry: true);
final stock = await repository.getItems();
expect(stock.single.name, 'greek yogurt');
expect(stock.single.quantityOnHand, 2);
expect(stock.single.unit, 'count');
expect(stock.single.category, PantryCategory.dairy);
});
test('syncWithMealPlan wires the planner window into the delta engine',
() async {
final recipeRepository = DriftRecipeRepository(database);
final mealPlanRepository = DriftMealPlanRepository(database);
final recipe = makeRecipe(
title: 'Lemon Herb Chicken',
ingredients: [ingredient('chicken breast', 800, 'g')],
);
await recipeRepository.saveRecipe(recipe);
await mealPlanRepository.saveSlot(
makeSlot(day: startOfWeek(DateTime.now()), recipeId: recipe.id),
);
final controller =
container.read(shoppingListControllerProvider.notifier);
final summary = await controller.syncWithMealPlan();
expect(summary, isNotNull);
expect(
container.read(shoppingListControllerProvider).statusMessage,
summary,
);
final list = await repository.getShoppingList();
expect(list, hasLength(1));
expect(list.single.isAutoGenerated, isTrue);
expect(list.single.requiredQuantity, 800);
});
});
group('PantryInventoryController', () {
test('addItem canonicalizes the name and quick adjust clamps at zero',
() async {
final controller =
container.read(pantryInventoryControllerProvider.notifier);
final saved = await controller.addItem(
name: 'Chicken Breasts',
quantityOnHand: 300,
unit: 'g',
category: PantryCategory.meatSeafood,
);
expect(saved, isTrue);
final stock = await repository.getItems();
expect(stock.single.name, 'chicken breast');
expect(stock.single.category, PantryCategory.meatSeafood);
await controller.adjustQuantity(stock.single, -500);
expect((await repository.getItems()).single.quantityOnHand, 0);
await controller.adjustQuantity(
(await repository.getItems()).single,
50,
);
expect((await repository.getItems()).single.quantityOnHand, 50);
});
});
}