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