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

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