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