738 lines
27 KiB
Dart
738 lines
27 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};
|
|
}
|
|
|
|
/// 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,
|
|
],
|
|
)
|
|
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 => 4;
|
|
|
|
@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);
|
|
}
|
|
},
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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);
|
|
|
|
/// 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),
|
|
]);
|
|
}
|
|
|
|
/// Application-wide database instance; closed when the container is disposed.
|
|
final appDatabaseProvider = Provider<AppDatabase>((ref) {
|
|
final database = AppDatabase();
|
|
ref.onDispose(database.close);
|
|
return database;
|
|
});
|