import 'package:drift/drift.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'connection/unsupported.dart' if (dart.library.ffi) 'connection/native.dart' if (dart.library.js_interop) 'connection/web.dart'; part 'app_database.g.dart'; /// Immutable log of GLP-1 injections and scheduled doses. /// /// [injectionSite] stores the anatomical rotation quadrant as a stable string /// value (the `InjectionQuadrant.name` identifier of the medication domain /// enum) so the persistence schema stays decoupled from domain types and the /// `core` layer never imports feature code. @DataClassName('MedicationDoseRecord') class MedicationDosesTable extends Table { IntColumn get id => integer().autoIncrement()(); /// Commercial or compounded name of the GLP-1/GIP agonist (e.g. Tirzepatide). TextColumn get compoundName => text().withLength(min: 1, max: 128)(); /// Total injected quantity in milligrams. RealColumn get doseMg => real()(); /// When the dose was prescribed to be taken (titration schedule anchor). DateTimeColumn get scheduledTime => dateTime()(); /// When the dose was actually administered; `null` while still pending. DateTimeColumn get administeredTime => dateTime().nullable()(); /// Rotation quadrant identifier (e.g. `abdomenLeftLower`). TextColumn get injectionSite => text().nullable()(); TextColumn get notes => text().nullable()(); } /// Immutable log of meal/snack intake for the nutrition diary. /// /// [mealType] stores the meal slot as a stable string (the `MealType.name` /// identifier of the nutrition domain enum) so the persistence schema stays /// decoupled from domain types and the `core` layer never imports feature /// code. @DataClassName('FoodLogRecord') class FoodLogsTable extends Table { IntColumn get id => integer().autoIncrement()(); /// When the food was consumed. DateTimeColumn get timestamp => dateTime()(); /// Meal slot identifier (e.g. `breakfast`). TextColumn get mealType => text().withLength(min: 1, max: 32)(); /// Free-text description of the meal. TextColumn get description => text().withLength(min: 1, max: 512)(); /// Protein content in grams (lean-mass tracking metric). RealColumn get proteinGrams => real()(); /// Energy content in kilocalories. IntColumn get calories => integer()(); /// Carbohydrate content in grams; optional. RealColumn get carbsGrams => real().nullable()(); /// Fat content in grams; optional. RealColumn get fatGrams => real().nullable()(); } /// Immutable log of fluid intake (US fluid ounces). @DataClassName('HydrationLogRecord') class HydrationLogsTable extends Table { IntColumn get id => integer().autoIncrement()(); /// When the fluid was consumed. DateTimeColumn get timestamp => dateTime()(); /// Consumed volume in fluid ounces. RealColumn get volumeOz => real()(); } /// Immutable biometric sample: weight (lbs) plus optional active energy burn. /// /// [source] stores the origin as a stable string (the `BiometricSource.name` /// identifier of the health-sync domain enum). @DataClassName('BiometricLogRecord') class BiometricLogsTable extends Table { IntColumn get id => integer().autoIncrement()(); /// When the sample was recorded. DateTimeColumn get timestamp => dateTime()(); /// Body weight in pounds. RealColumn get weightLbs => real()(); /// Active energy burned (kcal); `null` for weight-only samples. RealColumn get activeCaloriesBurned => real().nullable()(); /// Origin identifier (e.g. `appleHealth`, `manual`). TextColumn get source => text().withLength(min: 1, max: 32)(); } /// A saved recipe in the local catalog (imported via the web scraper or /// created manually). /// /// [id] is a UUID string assigned by the meal-planner domain; ingredient /// and instruction payloads are stored as JSON arrays so the core schema /// stays decoupled from the feature's entity types. @DataClassName('RecipeRecord') class RecipesTable extends Table { /// UUID primary key (see `core/utils/uuid.dart`). TextColumn get id => text()(); TextColumn get title => text().withLength(min: 1, max: 256)(); /// Canonical URL the recipe was imported from; used to de-duplicate /// re-imports. TextColumn get sourceUrl => text().withLength(min: 1, max: 1024)(); TextColumn get imageUrl => text().nullable()(); /// Short summary (schema.org `description` / `og:description`). TextColumn get description => text().nullable()(); /// Preparation time in minutes; `null` when unknown. IntColumn get prepTimeMinutes => integer().nullable()(); /// Cook time in minutes; `null` when unknown. IntColumn get cookTimeMinutes => integer().nullable()(); IntColumn get servings => integer().withDefault(const Constant(1))(); /// JSON-encoded `List` of ingredient records. TextColumn get rawIngredientsJson => text()(); /// JSON-encoded `List` of instruction steps. TextColumn get instructionsJson => text()(); IntColumn get calories => integer().withDefault(const Constant(0))(); RealColumn get proteinGrams => real().withDefault(const Constant(0))(); RealColumn get carbsGrams => real().withDefault(const Constant(0))(); RealColumn get fatGrams => real().withDefault(const Constant(0))(); DateTimeColumn get createdAt => dateTime()(); @override Set? get primaryKey => {id}; } /// One assignable slot of the weekly meal-plan calendar: a day plus a meal /// slot referencing a saved recipe (nullable FK) or free-text notes. @DataClassName('MealPlanSlotRecord') class MealPlanSlotsTable extends Table { /// UUID primary key. TextColumn get id => text()(); /// Calendar day the slot belongs to (local start-of-day). DateTimeColumn get plannedDate => dateTime()(); /// Meal slot identifier (e.g. `breakfast`) — the `MealType.name` key of /// the nutrition domain enum. TextColumn get mealType => text().withLength(min: 1, max: 32)(); /// Assigned recipe; falls back to `null` when the recipe is deleted. TextColumn get recipeId => text().nullable().references(RecipesTable, #id, onDelete: KeyAction.setNull)(); /// Free-text meal description for custom entries / notes. TextColumn get customNotes => text().nullable()(); BoolColumn get isCompleted => boolean().withDefault(const Constant(false))(); @override Set? get primaryKey => {id}; } /// A pantry stock record: how much of an ingredient is on hand, its grocery /// aisle, and when it expires. /// /// [name] stores the canonical lowercase ingredient key (see /// `features/pantry/domain/value_objects/ingredient_name.dart`) so the delta /// engine can fuzzy-match recipe lines against stock. The `core` layer never /// imports feature code. @DataClassName('PantryItemRecord') class PantryItemsTable extends Table { /// UUID primary key (see `core/utils/uuid.dart`). TextColumn get id => text()(); /// Canonical lowercase ingredient name (e.g. `chicken breast`). TextColumn get name => text().withLength(min: 1, max: 128)(); /// Quantity currently on hand, expressed in [unit]. RealColumn get quantityOnHand => real()(); /// Canonical unit key (e.g. `g`, `oz`, `count`). TextColumn get unit => text().withLength(min: 1, max: 16)(); /// Grocery aisle key (e.g. `meat_seafood`) — the `persistenceKey` of the /// pantry domain's category enum. TextColumn get category => text().withLength(min: 1, max: 32)(); /// Best-by date; `null` when the item does not expire. DateTimeColumn get expirationDate => dateTime().nullable()(); DateTimeColumn get lastUpdated => dateTime()(); @override Set? get primaryKey => {id}; } /// One row of the shopping checklist. /// /// Rows flagged [isAutoGenerated] are owned by the delta engine and /// recomputed on every meal-plan sync; manual rows are never touched by /// syncs. @DataClassName('ShoppingListItemRecord') class ShoppingListItemsTable extends Table { /// UUID primary key. TextColumn get id => text()(); TextColumn get name => text().withLength(min: 1, max: 128)(); /// Amount needed, expressed in [unit]. RealColumn get requiredQuantity => real()(); /// Canonical unit key (e.g. `g`, `count`). TextColumn get unit => text().withLength(min: 1, max: 16)(); BoolColumn get isPurchased => boolean().withDefault(const Constant(false))(); BoolColumn get isAutoGenerated => boolean().withDefault(const Constant(false))(); /// JSON-encoded `List` of contributing recipe titles for /// auto-generated rows; `null` for manual entries. TextColumn get sourceNotes => text().nullable()(); @override Set? get primaryKey => {id}; } /// Cached food product resolved by the nutritional search engine (Open /// Food Facts / USDA FoodData Central dual-source lookup). /// /// [id] is the stable source identifier (barcode or `fdc:`); the row /// doubles as the offline cache consulted before any remote lookup. @DataClassName('FoodProductRecord') class FoodProductsTable extends Table { /// Stable primary key: the source barcode when known, otherwise a /// source-prefixed identifier (e.g. `fdc:12345`). TextColumn get id => text()(); /// UPC/EAN barcode; `null` when the source did not provide one. TextColumn get barcode => text().nullable()(); TextColumn get productName => text().withLength(min: 1, max: 512)(); TextColumn get brand => text().nullable()(); /// Gram weight (or count) of one serving. RealColumn get servingSizeGrams => real()(); /// Canonical unit key for [servingSizeGrams] (e.g. `g`, `ml`, `count`). TextColumn get servingSizeUnit => text().withLength(min: 1, max: 16)(); /// Energy per serving, kilocalories. RealColumn get calories => real()(); RealColumn get proteinGrams => real()(); RealColumn get carbsGrams => real()(); RealColumn get fatGrams => real()(); RealColumn get fiberGrams => real().nullable()(); RealColumn get sodiumMg => real().nullable()(); TextColumn get imageUrl => text().nullable()(); DateTimeColumn get lastUpdated => dateTime()(); @override Set? get primaryKey => {id}; } /// Root drift database for the GLP-1 Companion. /// /// On native platforms the connection is backed by SQLCipher: the 256-bit AES /// master key provisioned by `CryptoManager` is applied via `PRAGMA key` /// immediately after the database opens (see `connection/native.dart`). Web /// builds transparently fall back to a WASM SQLite database, since SQLCipher /// cannot run inside the browser. @DriftDatabase( tables: [ MedicationDosesTable, FoodLogsTable, HydrationLogsTable, BiometricLogsTable, RecipesTable, MealPlanSlotsTable, PantryItemsTable, ShoppingListItemsTable, FoodProductsTable, ], ) class AppDatabase extends _$AppDatabase { /// Opens the platform-appropriate (encrypted on native) database connection. AppDatabase() : super(openDatabaseConnection()); /// Injects a custom executor; used by unit tests with an in-memory database. AppDatabase.forTesting(super.executor); @override int get schemaVersion => 5; @override MigrationStrategy get migration => MigrationStrategy( onCreate: (m) => m.createAll(), onUpgrade: (m, from, to) async { // v2 added the nutrition and biometric tables; existing v1 // installations (medication only) receive them in place. if (from < 2) { await m.createTable(foodLogsTable); await m.createTable(hydrationLogsTable); await m.createTable(biometricLogsTable); } // v3 added the recipe catalog and weekly meal-plan slots // (recipes first: meal-plan slots reference them). if (from < 3) { await m.createTable(recipesTable); await m.createTable(mealPlanSlotsTable); } // v4 added the pantry inventory and shopping checklist. if (from < 4) { await m.createTable(pantryItemsTable); await m.createTable(shoppingListItemsTable); } // v5 added the dual-source (Open Food Facts / USDA) food cache. if (from < 5) { await m.createTable(foodProductsTable); } }, ); // --------------------------------------------------------------------------- // Injection history CRUD // --------------------------------------------------------------------------- // // These helpers intentionally operate on drift records/companions only: // the `core` layer never imports feature domain types, so mapping between // `MedicationDoseRecord` and the medication feature's `DoseEntry` entity // happens in `features/medication/data/repositories/medication_repository.dart`. /// Emits the stored dose history, newest first, and re-emits on every write. /// /// Ordering uses the administered time (falling back to the scheduled time /// for pending rows) so backdated entries sort chronologically. Stream> watchDoseEntries({int limit = 500}) => (_selectDoseEntries()..limit(limit)).watch(); /// One-shot snapshot of the dose history, newest first. Future> getDoseEntries({int limit = 500}) => (_selectDoseEntries()..limit(limit)).get(); /// Inserts a new row and returns the persisted record including its /// auto-incremented primary key. Future insertDoseEntry( MedicationDosesTableCompanion entry, ) async { final id = await into(medicationDosesTable).insert(entry); return (select(medicationDosesTable)..where((t) => t.id.equals(id))) .getSingle(); } /// Partially updates the row identified by [id] (only non-absent companion /// columns are written). Future updateDoseEntry( int id, MedicationDosesTableCompanion updates, ) => (update(medicationDosesTable)..where((t) => t.id.equals(id))).write( updates, ); /// Deletes the row identified by [id]; returns the number of removed rows. Future deleteDoseEntry(int id) => (delete(medicationDosesTable)..where((t) => t.id.equals(id))).go(); /// Most recent row that was actually administered, or `null` when no dose /// has been logged yet. Future latestAdministeredDose() => (select(medicationDosesTable) ..where((t) => t.administeredTime.isNotNull()) ..orderBy([ (t) => OrderingTerm.desc(t.administeredTime), (t) => OrderingTerm.desc(t.id), ]) ..limit(1)) .getSingleOrNull(); /// Shared newest-first ordering for [watchDoseEntries] and [getDoseEntries]. SimpleSelectStatement<$MedicationDosesTableTable, MedicationDoseRecord> _selectDoseEntries() => select(medicationDosesTable) ..orderBy([ (t) => OrderingTerm.desc(t.administeredTime), (t) => OrderingTerm.desc(t.id), ]); // --------------------------------------------------------------------------- // Food diary CRUD // --------------------------------------------------------------------------- // // Same contract as the dose helpers: persistence records only — mapping to // the nutrition feature's `FoodLogEntry` happens in // `features/nutrition/data/repositories/nutrition_repository.dart`. /// Emits the food diary, newest first, and re-emits on every write. /// /// When [from]/[to] are provided only rows in the half-open interval /// `[from, to)` are emitted (used for the "today" view). Stream> watchFoodLogs({ int limit = 500, DateTime? from, DateTime? to, }) => (_selectFoodLogs(from: from, to: to)..limit(limit)).watch(); /// One-shot snapshot of the food diary, newest first. Future> getFoodLogs({ int limit = 500, DateTime? from, DateTime? to, }) => (_selectFoodLogs(from: from, to: to)..limit(limit)).get(); /// Inserts a new row and returns the persisted record including its /// auto-incremented primary key. Future insertFoodLog(FoodLogsTableCompanion entry) async { final id = await into(foodLogsTable).insert(entry); return (select(foodLogsTable)..where((t) => t.id.equals(id))).getSingle(); } /// Partially updates the row identified by [id] (only non-absent companion /// columns are written). Future updateFoodLog(int id, FoodLogsTableCompanion updates) => (update(foodLogsTable)..where((t) => t.id.equals(id))).write(updates); /// Deletes the food row identified by [id]; returns the number of removed /// rows. Future deleteFoodLog(int id) => (delete(foodLogsTable)..where((t) => t.id.equals(id))).go(); /// Newest-first food query shared by [watchFoodLogs] and [getFoodLogs]. SimpleSelectStatement<$FoodLogsTableTable, FoodLogRecord> _selectFoodLogs({ DateTime? from, DateTime? to, }) { final query = select(foodLogsTable); if (from != null) { query.where((t) => t.timestamp.isBiggerOrEqualValue(from)); } if (to != null) { query.where((t) => t.timestamp.isSmallerThanValue(to)); } return query ..orderBy([ (t) => OrderingTerm.desc(t.timestamp), (t) => OrderingTerm.desc(t.id), ]); } // --------------------------------------------------------------------------- // Hydration CRUD // --------------------------------------------------------------------------- /// Emits the hydration log, newest first, and re-emits on every write. Stream> watchHydrationLogs({ int limit = 500, DateTime? from, DateTime? to, }) => (_selectHydrationLogs(from: from, to: to)..limit(limit)).watch(); /// One-shot snapshot of the hydration log, newest first. Future> getHydrationLogs({ int limit = 500, DateTime? from, DateTime? to, }) => (_selectHydrationLogs(from: from, to: to)..limit(limit)).get(); /// Inserts a new row and returns the persisted record including its /// auto-incremented primary key. Future insertHydrationLog( HydrationLogsTableCompanion entry, ) async { final id = await into(hydrationLogsTable).insert(entry); return (select(hydrationLogsTable)..where((t) => t.id.equals(id))) .getSingle(); } /// Partially updates the row identified by [id]. Future updateHydrationLog( int id, HydrationLogsTableCompanion updates, ) => (update(hydrationLogsTable)..where((t) => t.id.equals(id))).write(updates); /// Deletes the hydration row identified by [id]. Future deleteHydrationLog(int id) => (delete(hydrationLogsTable)..where((t) => t.id.equals(id))).go(); /// Newest-first hydration query shared by the watch/get helpers. SimpleSelectStatement<$HydrationLogsTableTable, HydrationLogRecord> _selectHydrationLogs({DateTime? from, DateTime? to}) { final query = select(hydrationLogsTable); if (from != null) { query.where((t) => t.timestamp.isBiggerOrEqualValue(from)); } if (to != null) { query.where((t) => t.timestamp.isSmallerThanValue(to)); } return query ..orderBy([ (t) => OrderingTerm.desc(t.timestamp), (t) => OrderingTerm.desc(t.id), ]); } // --------------------------------------------------------------------------- // Biometric CRUD // --------------------------------------------------------------------------- /// Emits the biometric history, newest first, and re-emits on every write. Stream> watchBiometrics({ int limit = 500, DateTime? from, DateTime? to, }) => (_selectBiometrics(from: from, to: to)..limit(limit)).watch(); /// One-shot snapshot of the biometric history, newest first. Future> getBiometrics({ int limit = 500, DateTime? from, DateTime? to, }) => (_selectBiometrics(from: from, to: to)..limit(limit)).get(); /// Inserts a new row and returns the persisted record including its /// auto-incremented primary key. Future insertBiometricLog( BiometricLogsTableCompanion entry, ) async { final id = await into(biometricLogsTable).insert(entry); return (select(biometricLogsTable)..where((t) => t.id.equals(id))) .getSingle(); } /// Partially updates the row identified by [id]. Future updateBiometricLog( int id, BiometricLogsTableCompanion updates, ) => (update(biometricLogsTable)..where((t) => t.id.equals(id))) .write(updates); /// Deletes the biometric row identified by [id]. Future deleteBiometricLog(int id) => (delete(biometricLogsTable)..where((t) => t.id.equals(id))).go(); /// Most recent biometric row, or `null` on an empty history. Future latestBiometric() => (select(biometricLogsTable) ..orderBy([ (t) => OrderingTerm.desc(t.timestamp), (t) => OrderingTerm.desc(t.id), ]) ..limit(1)) .getSingleOrNull(); /// Deletes rows produced by [sourceName] within `[start, end)`; used to /// make health re-syncs idempotent. Future deleteBiometricsFromSourceWithin({ required String sourceName, required DateTime start, required DateTime end, }) => (delete(biometricLogsTable) ..where( (t) => t.source.equals(sourceName) & t.timestamp.isBiggerOrEqualValue(start) & t.timestamp.isSmallerThanValue(end), )) .go(); /// Newest-first biometric query shared by the watch/get helpers. SimpleSelectStatement<$BiometricLogsTableTable, BiometricLogRecord> _selectBiometrics({DateTime? from, DateTime? to}) { final query = select(biometricLogsTable); if (from != null) { query.where((t) => t.timestamp.isBiggerOrEqualValue(from)); } if (to != null) { query.where((t) => t.timestamp.isSmallerThanValue(to)); } return query ..orderBy([ (t) => OrderingTerm.desc(t.timestamp), (t) => OrderingTerm.desc(t.id), ]); } // --------------------------------------------------------------------------- // Recipe catalog CRUD // --------------------------------------------------------------------------- // // These helpers intentionally operate on drift records/companions only: // mapping between `RecipeRecord` and the meal-planner feature's `Recipe` // entity happens in `features/meal_planner/data/repositories/`. /// Emits the saved recipe catalog (newest first) and re-emits on every /// write. Stream> watchRecipes() => (_selectRecipes()).watch(); /// One-shot snapshot of the saved recipe catalog (newest first). Future> getRecipes() => _selectRecipes().get(); /// The recipe row with [id], or `null` when absent. Future getRecipeById(String id) => (select(recipesTable)..where((t) => t.id.equals(id))).getSingleOrNull(); /// The most recent recipe imported from [sourceUrl], or `null`; used to /// de-duplicate re-imports. Future getRecipeBySourceUrl(String sourceUrl) => (select(recipesTable) ..where((t) => t.sourceUrl.equals(sourceUrl)) ..limit(1)) .getSingleOrNull(); /// Inserts a new recipe row. Future insertRecipe(RecipesTableCompanion entry) => into(recipesTable).insert(entry); /// Partially updates the recipe identified by [id] (only non-absent /// companion columns are written, so `createdAt` survives re-imports). Future updateRecipe(String id, RecipesTableCompanion updates) => (update(recipesTable)..where((t) => t.id.equals(id))).write(updates); /// Deletes the recipe with [id]; referencing meal-plan slots receive a /// `null` recipe id (`ON DELETE SET NULL`). Future deleteRecipe(String id) => (delete(recipesTable)..where((t) => t.id.equals(id))).go(); /// Catalog query shared by the watch/get helpers, newest first. SimpleSelectStatement<$RecipesTableTable, RecipeRecord> _selectRecipes() => select(recipesTable) ..orderBy([ (t) => OrderingTerm.desc(t.createdAt), (t) => OrderingTerm.desc(t.id), ]); // --------------------------------------------------------------------------- // Meal-plan slot CRUD // --------------------------------------------------------------------------- /// Emits the meal-plan slots in the half-open window `[from, to)` and /// re-emits on every write. Stream> watchMealPlanSlots({ required DateTime from, required DateTime to, }) => _selectMealPlanSlots(from: from, to: to).watch(); /// One-shot snapshot of the meal-plan slots in `[from, to)`, ordered by /// day then slot id. Future> getMealPlanSlots({ required DateTime from, required DateTime to, }) => _selectMealPlanSlots(from: from, to: to).get(); /// Inserts the slot or updates the existing row with the same id. Future upsertMealPlanSlot(MealPlanSlotsTableCompanion slot) => into(mealPlanSlotsTable).insertOnConflictUpdate(slot); /// Deletes the meal-plan slot with [id], if present. Future deleteMealPlanSlot(String id) => (delete(mealPlanSlotsTable)..where((t) => t.id.equals(id))).go(); /// Window query shared by the watch/get helpers. SimpleSelectStatement<$MealPlanSlotsTableTable, MealPlanSlotRecord> _selectMealPlanSlots({required DateTime from, required DateTime to}) => (select(mealPlanSlotsTable) ..where( (t) => t.plannedDate.isBiggerOrEqualValue(from) & t.plannedDate.isSmallerThanValue(to), )) ..orderBy([ (t) => OrderingTerm.asc(t.plannedDate), (t) => OrderingTerm.asc(t.id), ]); // --------------------------------------------------------------------------- // Pantry inventory CRUD // --------------------------------------------------------------------------- // // Records/companions only: mapping to the pantry feature's `PantryItem` // entity happens in `features/pantry/data/repositories/`. /// Emits pantry stock (alphabetical) and re-emits on every write. Stream> watchPantryItems() => _selectPantryItems().watch(); /// One-shot snapshot of pantry stock (alphabetical). Future> getPantryItems() => _selectPantryItems().get(); /// The pantry row with [id], or `null` when absent. Future getPantryItemById(String id) => (select(pantryItemsTable)..where((t) => t.id.equals(id))) .getSingleOrNull(); /// Inserts the pantry row or updates the existing row with the same id. Future upsertPantryItem(PantryItemsTableCompanion entry) => into(pantryItemsTable).insertOnConflictUpdate(entry); /// Deletes the pantry row with [id], if present. Future deletePantryItem(String id) => (delete(pantryItemsTable)..where((t) => t.id.equals(id))).go(); /// Alphabetical query shared by the watch/get helpers. SimpleSelectStatement<$PantryItemsTableTable, PantryItemRecord> _selectPantryItems() => select(pantryItemsTable) ..orderBy([ (t) => OrderingTerm.asc(t.name), (t) => OrderingTerm.asc(t.id), ]); // --------------------------------------------------------------------------- // Shopping list CRUD // --------------------------------------------------------------------------- /// Emits the shopping checklist (unchecked rows first) and re-emits on /// every write. Stream> watchShoppingListItems() => _selectShoppingListItems().watch(); /// One-shot snapshot of the shopping checklist (unchecked first). Future> getShoppingListItems() => _selectShoppingListItems().get(); /// The checklist row with [id], or `null` when absent. Future getShoppingListItemById(String id) => (select(shoppingListItemsTable)..where((t) => t.id.equals(id))) .getSingleOrNull(); /// Inserts the checklist row or updates the existing row with the same id. Future upsertShoppingListItem(ShoppingListItemsTableCompanion entry) => into(shoppingListItemsTable).insertOnConflictUpdate(entry); /// Deletes the checklist row with [id], if present. Future deleteShoppingListItem(String id) => (delete(shoppingListItemsTable)..where((t) => t.id.equals(id))).go(); /// Purchase-state query shared by the watch/get helpers. SimpleSelectStatement<$ShoppingListItemsTableTable, ShoppingListItemRecord> _selectShoppingListItems() => select(shoppingListItemsTable) ..orderBy([ (t) => OrderingTerm.asc(t.isPurchased), (t) => OrderingTerm.asc(t.name), (t) => OrderingTerm.asc(t.id), ]); // --------------------------------------------------------------------------- // Food product cache CRUD (nutritional search engine) // --------------------------------------------------------------------------- // // The dual-source (Open Food Facts / USDA) search repository reads these // rows before going to the network and writes every remote hit back through // [upsertFoodProduct] so repeated lookups work offline. /// The cached product row with [id], or `null` when absent. Future getFoodProductById(String id) => (select(foodProductsTable)..where((t) => t.id.equals(id))) .getSingleOrNull(); /// The cached product row carrying [barcode], or `null` when absent. Future getFoodProductByBarcode(String barcode) => (select(foodProductsTable)..where((t) => t.barcode.equals(barcode))) .getSingleOrNull(); /// Case-insensitive substring matches on product name or brand, most /// recently updated first. Wildcard characters in [query] are stripped so /// user input cannot inject `LIKE` patterns (SQLite's `LIKE` is already /// case-insensitive for ASCII text). Future> searchFoodProducts( String query, { int limit = 25, }) { final sanitized = query.replaceAll(RegExp(r'[%_\\]'), ' ').trim(); if (sanitized.isEmpty) return Future.value(const []); final pattern = '%$sanitized%'; return (select(foodProductsTable) ..where( (t) => t.productName.like(pattern) | t.brand.like(pattern), ) ..orderBy([ (t) => OrderingTerm.desc(t.lastUpdated), ]) ..limit(limit)) .get(); } /// Inserts the product row or refreshes the existing row with the same id. Future upsertFoodProduct(FoodProductsTableCompanion entry) => into(foodProductsTable).insertOnConflictUpdate(entry); } /// Application-wide database instance; closed when the container is disposed. final appDatabaseProvider = Provider((ref) { final database = AppDatabase(); ref.onDispose(database.close); return database; });