glp-companion/lib/core/database/app_database.dart

839 lines
31 KiB
Dart

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<Map>` of ingredient records.
TextColumn get rawIngredientsJson => text()();
/// JSON-encoded `List<String>` 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<Column>? 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<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};
}
/// 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:<id>`); 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<Column>? get primaryKey => {id};
}
/// Root drift database for the GLP-1 Companion.
///
/// On native platforms the connection is backed by SQLCipher: the 256-bit AES
/// 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<List<MedicationDoseRecord>> watchDoseEntries({int limit = 500}) =>
(_selectDoseEntries()..limit(limit)).watch();
/// One-shot snapshot of the dose history, newest first.
Future<List<MedicationDoseRecord>> getDoseEntries({int limit = 500}) =>
(_selectDoseEntries()..limit(limit)).get();
/// Inserts a new row and returns the persisted record including its
/// auto-incremented primary key.
Future<MedicationDoseRecord> 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<void> 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<int> 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<MedicationDoseRecord?> 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<List<FoodLogRecord>> 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<List<FoodLogRecord>> 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<FoodLogRecord> 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<void> 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<int> 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<List<HydrationLogRecord>> 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<List<HydrationLogRecord>> 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<HydrationLogRecord> 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<void> updateHydrationLog(
int id,
HydrationLogsTableCompanion updates,
) =>
(update(hydrationLogsTable)..where((t) => t.id.equals(id))).write(updates);
/// Deletes the hydration row identified by [id].
Future<int> 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<List<BiometricLogRecord>> 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<List<BiometricLogRecord>> 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<BiometricLogRecord> 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<void> updateBiometricLog(
int id,
BiometricLogsTableCompanion updates,
) =>
(update(biometricLogsTable)..where((t) => t.id.equals(id)))
.write(updates);
/// Deletes the biometric row identified by [id].
Future<int> deleteBiometricLog(int id) =>
(delete(biometricLogsTable)..where((t) => t.id.equals(id))).go();
/// Most recent biometric row, or `null` on an empty history.
Future<BiometricLogRecord?> 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<int> 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<List<RecipeRecord>> watchRecipes() =>
(_selectRecipes()).watch();
/// One-shot snapshot of the saved recipe catalog (newest first).
Future<List<RecipeRecord>> getRecipes() => _selectRecipes().get();
/// The recipe row with [id], or `null` when absent.
Future<RecipeRecord?> 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<RecipeRecord?> getRecipeBySourceUrl(String sourceUrl) =>
(select(recipesTable)
..where((t) => t.sourceUrl.equals(sourceUrl))
..limit(1))
.getSingleOrNull();
/// Inserts a new recipe row.
Future<void> insertRecipe(RecipesTableCompanion entry) =>
into(recipesTable).insert(entry);
/// Inserts or updates a recipe row (`INSERT … ON CONFLICT(id) DO UPDATE`),
/// the write path for Mealie re-syncs where the primary key is the stable
/// Mealie recipe UUID (only the columns present in [entry] are written, so
/// an absent `createdAt` survives the upsert).
Future<void> upsertRecipe(RecipesTableCompanion entry) =>
into(recipesTable).insertOnConflictUpdate(entry);
/// Partially updates the recipe identified by [id] (only non-absent
/// companion columns are written, so `createdAt` survives re-imports).
Future<void> 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<int> 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<List<MealPlanSlotRecord>> 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<List<MealPlanSlotRecord>> 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<void> upsertMealPlanSlot(MealPlanSlotsTableCompanion slot) =>
into(mealPlanSlotsTable).insertOnConflictUpdate(slot);
/// Deletes the meal-plan slot with [id], if present.
Future<int> 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<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),
]);
// ---------------------------------------------------------------------------
// 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<FoodProductRecord?> getFoodProductById(String id) =>
(select(foodProductsTable)..where((t) => t.id.equals(id)))
.getSingleOrNull();
/// The cached product row carrying [barcode], or `null` when absent.
Future<FoodProductRecord?> 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<List<FoodProductRecord>> 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<void> upsertFoodProduct(FoodProductsTableCompanion entry) =>
into(foodProductsTable).insertOnConflictUpdate(entry);
}
/// Application-wide database instance; closed when the container is disposed.
final appDatabaseProvider = Provider<AppDatabase>((ref) {
final database = AppDatabase();
ref.onDispose(database.close);
return database;
});