From 094cf08f2569ead590d4c35fd9f03b7df5d89495 Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Fri, 16 Oct 2026 07:02:28 -0400 Subject: [PATCH] feat(nutrition,health_sync): complete Phase 3 nutrition tracking and HealthGateway sync - Domain: FoodLogEntry/MealType, HydrationLogEntry, BiometricEntry/BiometricSource entities plus INutritionRepository and IHealthSyncRepository contracts (pure Dart). - Data: AppDatabase schema v2 (FoodLogs/HydrationLogs/BiometricLogs tables, v1 migration, range-filtered CRUD) with drift-backed NutritionRepository and HealthSyncRepository; startOfDay/endOfDay date utils. - Platform: HealthGateway conditional-import factory - mobile impl wires package:health (WEIGHT kg->lb, ACTIVE_ENERGY_BURNED J->kcal, permissions, write-back) with guarded fallbacks; web/desktop impl never touches platform channels (manual entry + drift cache). - Presentation: NutritionController and BiometricController AsyncNotifiers (100g protein / 96oz water targets, meal grouping, 7/30-day weight deltas, idempotent 90-day native sync with per-day active-energy aggregation); LogFoodDialog and LogWeightDialog. - UI: NutritionScreen progress meters, +8/+16/+24oz quick-add, meal-grouped food diary, weight tracker card with Log Weight / Sync (native sync on Android/iOS, manual dialog on Windows/Web) and status snackbars. - Tests: 23 new tests (in-memory drift repos, controller fakes, fake health gateway covering fallback, permission denial, and duplicate-free re-sync); 60/60 passing, flutter analyze clean, flutter build web succeeds. BUILD_STATE.md updated to Phase 4. --- BUILD_STATE.md | 20 +- lib/core/database/app_database.dart | 291 ++- lib/core/database/app_database.g.dart | 1555 ++++++++++++++++- lib/core/platform/health_gateway.dart | 105 +- .../platform/health_gateway_fallback.dart | 45 + lib/core/platform/health_gateway_mobile.dart | 167 ++ lib/core/utils/date_time_utils.dart | 11 + .../repositories/health_sync_repository.dart | 93 + .../domain/entities/biometric_entry.dart | 76 + .../repositories/health_sync_repository.dart | 38 + .../controllers/biometric_controller.dart | 388 ++++ .../widgets/log_weight_dialog.dart | 147 ++ .../repositories/nutrition_repository.dart | 146 ++ .../domain/entities/food_log_entry.dart | 66 + .../domain/entities/hydration_log_entry.dart | 30 + .../nutrition/domain/entities/meal_type.dart | 37 + .../repositories/nutrition_repository.dart | 57 + .../controllers/nutrition_controller.dart | 251 +++ .../screens/nutrition_screen.dart | 681 +++++++- .../presentation/widgets/log_food_dialog.dart | 230 +++ .../biometric_controller_test.dart | 346 ++++ .../data/nutrition_repository_test.dart | 162 ++ .../nutrition_controller_test.dart | 291 +++ 23 files changed, 5142 insertions(+), 91 deletions(-) create mode 100644 lib/core/platform/health_gateway_fallback.dart create mode 100644 lib/core/platform/health_gateway_mobile.dart create mode 100644 lib/features/health_sync/data/repositories/health_sync_repository.dart create mode 100644 lib/features/health_sync/domain/entities/biometric_entry.dart create mode 100644 lib/features/health_sync/domain/repositories/health_sync_repository.dart create mode 100644 lib/features/health_sync/presentation/controllers/biometric_controller.dart create mode 100644 lib/features/health_sync/presentation/widgets/log_weight_dialog.dart create mode 100644 lib/features/nutrition/data/repositories/nutrition_repository.dart create mode 100644 lib/features/nutrition/domain/entities/food_log_entry.dart create mode 100644 lib/features/nutrition/domain/entities/hydration_log_entry.dart create mode 100644 lib/features/nutrition/domain/entities/meal_type.dart create mode 100644 lib/features/nutrition/domain/repositories/nutrition_repository.dart create mode 100644 lib/features/nutrition/presentation/controllers/nutrition_controller.dart create mode 100644 lib/features/nutrition/presentation/widgets/log_food_dialog.dart create mode 100644 test/features/health_sync/presentation/biometric_controller_test.dart create mode 100644 test/features/nutrition/data/nutrition_repository_test.dart create mode 100644 test/features/nutrition/presentation/nutrition_controller_test.dart diff --git a/BUILD_STATE.md b/BUILD_STATE.md index d4144e3..bc73080 100644 --- a/BUILD_STATE.md +++ b/BUILD_STATE.md @@ -1,13 +1,13 @@ # Build Execution Ledger ## Active Target -- **Current Milestone:** Phase 3: Nutrition, Protein Target Tracking & HealthGateway Sync -- **Status:** Phase 2 Complete +- **Current Milestone:** Phase 4: Recipe Web Scraper Engine & Meal Planning Calendar +- **Status:** Phase 3 Complete ## Phase Checklist - [x] Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB) - [x] Phase 2: Medication Domain, Injection Rotation, Serum Modeling -- [ ] Phase 3: Nutrition, Protein Target Tracking, HealthGateway Sync +- [x] Phase 3: Nutrition, Protein Target Tracking, HealthGateway Sync - [ ] Phase 4: Recipe Web Scraper Engine & Meal Planning Calendar - [ ] Phase 5: Pantry Delta Engine & Auto-Shopping Generator - [ ] Phase 6: Hardening, Native Desktop/Web Polish, CI/CD @@ -24,7 +24,17 @@ ## Post-Phase-2 Additions - [x] Development auth bypass (2026-10-16): debug-only "Continue Without Signing In" button on the login screen (`kDebugMode`-gated, in-memory `devAuthBypassProvider`) lets reviewers enter the app without a Supabase session; the router guard honors the flag and the shell shows an orange "Development mode - not signed in" banner while active. Covered by `test/features/auth/dev_auth_bypass_test.dart` (3 tests). +## Phase 3 Deliverables (2026-10-16) +- [x] Domain: `FoodLogEntry` (+ `MealType` enum), `HydrationLogEntry`, `BiometricEntry` (+ `BiometricSource` enum), `INutritionRepository`, `IHealthSyncRepository` — all pure Dart. +- [x] Data: `AppDatabase` schema v2 (+ migration for v1 installs) with `FoodLogsTable`, `HydrationLogsTable`, `BiometricLogsTable`, range-filtered watch/get/CRUD helpers, `clearSyncedRange` for idempotent re-syncs; drift-backed `NutritionRepository` and `HealthSyncRepository` (record ↔ entity mapping). +- [x] Platform: `HealthGateway` rewrite — `IHealthGateway` + conditional-import factory; mobile impl (`health_gateway_mobile.dart`) wraps `package:health` for `WEIGHT` (kg→lb normalization) and `ACTIVE_ENERGY_BURNED` (joule→kcal) with double-guarded try/catch; fallback impl (`health_gateway_fallback.dart`) for Web/Windows/macOS never touches platform channels (reads served from the drift cache, writes via manual entry). +- [x] Presentation: `NutritionController` (Riverpod `AsyncNotifier`; today's logs, 100 g protein / 96 oz water targets, meal grouping, quick-add water) and `BiometricController` (weight history, 7/30-day deltas, manual entry, native sync with per-day active-energy aggregation); `LogFoodDialog` and `LogWeightDialog`. +- [x] Presentation: `NutritionScreen` upgraded — interactive protein/hydration progress meters, +8/+16/+24 oz quick-add buttons, meal-grouped food diary with delete, weight tracker card (latest weight, 7/30-day trend chips, latest active burn) whose "Log Weight / Sync" button runs `HealthGateway` sync on Android/iOS and opens the manual dialog on Windows/Web; status snackbars via `ref.listen`. +- [x] Tests: 23 new tests — nutrition repository (in-memory drift: CRUD, day-range filter, watch, `MealType` round-trip), nutrition controller (targets, grouping, validation, quick-add, delete, failure retention), biometric controller (manual entry, deltas, fallback short-circuit, permission denial, weight+energy sync idempotency). +- [x] Regression fix: none required — existing 37 tests untouched and green. + ## Active Verification Gates - [x] `flutter pub run build_runner build --delete-conflicting-outputs` -- [x] `flutter analyze` (Zero issues) -- [x] `flutter test` (37/37 passing) \ No newline at end of file +- [x] `flutter analyze` (Zero issues — `No issues found!`) +- [x] `flutter test` (60/60 passing) +- [x] `flutter build web --no-tree-shake-icons` (conditional import keeps `package:health`/`dart:io` out of the web bundle) \ No newline at end of file diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart index 0ce7b6d..766b119 100644 --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -35,6 +35,71 @@ class MedicationDosesTable extends Table { 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 @@ -42,7 +107,9 @@ class MedicationDosesTable extends Table { /// 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]) +@DriftDatabase( + tables: [MedicationDosesTable, FoodLogsTable, HydrationLogsTable, BiometricLogsTable], +) class AppDatabase extends _$AppDatabase { /// Opens the platform-appropriate (encrypted on native) database connection. AppDatabase() : super(openDatabaseConnection()); @@ -51,7 +118,21 @@ class AppDatabase extends _$AppDatabase { AppDatabase.forTesting(super.executor); @override - int get schemaVersion => 1; + 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 @@ -116,6 +197,212 @@ class AppDatabase extends _$AppDatabase { (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. diff --git a/lib/core/database/app_database.g.dart b/lib/core/database/app_database.g.dart index d93b592..096c177 100644 --- a/lib/core/database/app_database.g.dart +++ b/lib/core/database/app_database.g.dart @@ -420,16 +420,1022 @@ class MedicationDosesTableCompanion } } +class $FoodLogsTableTable extends FoodLogsTable + with TableInfo<$FoodLogsTableTable, FoodLogRecord> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $FoodLogsTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + static const VerificationMeta _timestampMeta = + const VerificationMeta('timestamp'); + @override + late final GeneratedColumn timestamp = GeneratedColumn( + 'timestamp', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + static const VerificationMeta _mealTypeMeta = + const VerificationMeta('mealType'); + @override + late final GeneratedColumn mealType = GeneratedColumn( + 'meal_type', aliasedName, false, + additionalChecks: + GeneratedColumn.checkTextLength(minTextLength: 1, maxTextLength: 32), + type: DriftSqlType.string, + requiredDuringInsert: true); + static const VerificationMeta _descriptionMeta = + const VerificationMeta('description'); + @override + late final GeneratedColumn description = GeneratedColumn( + 'description', aliasedName, false, + additionalChecks: + GeneratedColumn.checkTextLength(minTextLength: 1, maxTextLength: 512), + type: DriftSqlType.string, + requiredDuringInsert: true); + static const VerificationMeta _proteinGramsMeta = + const VerificationMeta('proteinGrams'); + @override + late final GeneratedColumn proteinGrams = GeneratedColumn( + 'protein_grams', aliasedName, false, + type: DriftSqlType.double, requiredDuringInsert: true); + static const VerificationMeta _caloriesMeta = + const VerificationMeta('calories'); + @override + late final GeneratedColumn calories = GeneratedColumn( + 'calories', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + static const VerificationMeta _carbsGramsMeta = + const VerificationMeta('carbsGrams'); + @override + late final GeneratedColumn carbsGrams = GeneratedColumn( + 'carbs_grams', aliasedName, true, + type: DriftSqlType.double, requiredDuringInsert: false); + static const VerificationMeta _fatGramsMeta = + const VerificationMeta('fatGrams'); + @override + late final GeneratedColumn fatGrams = GeneratedColumn( + 'fat_grams', aliasedName, true, + type: DriftSqlType.double, requiredDuringInsert: false); + @override + List get $columns => [ + id, + timestamp, + mealType, + description, + proteinGrams, + calories, + carbsGrams, + fatGrams + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'food_logs_table'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('timestamp')) { + context.handle(_timestampMeta, + timestamp.isAcceptableOrUnknown(data['timestamp']!, _timestampMeta)); + } else if (isInserting) { + context.missing(_timestampMeta); + } + if (data.containsKey('meal_type')) { + context.handle(_mealTypeMeta, + mealType.isAcceptableOrUnknown(data['meal_type']!, _mealTypeMeta)); + } else if (isInserting) { + context.missing(_mealTypeMeta); + } + if (data.containsKey('description')) { + context.handle( + _descriptionMeta, + description.isAcceptableOrUnknown( + data['description']!, _descriptionMeta)); + } else if (isInserting) { + context.missing(_descriptionMeta); + } + if (data.containsKey('protein_grams')) { + context.handle( + _proteinGramsMeta, + proteinGrams.isAcceptableOrUnknown( + data['protein_grams']!, _proteinGramsMeta)); + } else if (isInserting) { + context.missing(_proteinGramsMeta); + } + if (data.containsKey('calories')) { + context.handle(_caloriesMeta, + calories.isAcceptableOrUnknown(data['calories']!, _caloriesMeta)); + } else if (isInserting) { + context.missing(_caloriesMeta); + } + if (data.containsKey('carbs_grams')) { + context.handle( + _carbsGramsMeta, + carbsGrams.isAcceptableOrUnknown( + data['carbs_grams']!, _carbsGramsMeta)); + } + if (data.containsKey('fat_grams')) { + context.handle(_fatGramsMeta, + fatGrams.isAcceptableOrUnknown(data['fat_grams']!, _fatGramsMeta)); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + FoodLogRecord map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return FoodLogRecord( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + timestamp: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}timestamp'])!, + mealType: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}meal_type'])!, + description: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}description'])!, + proteinGrams: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}protein_grams'])!, + calories: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}calories'])!, + carbsGrams: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}carbs_grams']), + fatGrams: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}fat_grams']), + ); + } + + @override + $FoodLogsTableTable createAlias(String alias) { + return $FoodLogsTableTable(attachedDatabase, alias); + } +} + +class FoodLogRecord extends DataClass implements Insertable { + final int id; + + /// When the food was consumed. + final DateTime timestamp; + + /// Meal slot identifier (e.g. `breakfast`). + final String mealType; + + /// Free-text description of the meal. + final String description; + + /// Protein content in grams (lean-mass tracking metric). + final double proteinGrams; + + /// Energy content in kilocalories. + final int calories; + + /// Carbohydrate content in grams; optional. + final double? carbsGrams; + + /// Fat content in grams; optional. + final double? fatGrams; + const FoodLogRecord( + {required this.id, + required this.timestamp, + required this.mealType, + required this.description, + required this.proteinGrams, + required this.calories, + this.carbsGrams, + this.fatGrams}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['timestamp'] = Variable(timestamp); + map['meal_type'] = Variable(mealType); + map['description'] = Variable(description); + map['protein_grams'] = Variable(proteinGrams); + map['calories'] = Variable(calories); + if (!nullToAbsent || carbsGrams != null) { + map['carbs_grams'] = Variable(carbsGrams); + } + if (!nullToAbsent || fatGrams != null) { + map['fat_grams'] = Variable(fatGrams); + } + return map; + } + + FoodLogsTableCompanion toCompanion(bool nullToAbsent) { + return FoodLogsTableCompanion( + id: Value(id), + timestamp: Value(timestamp), + mealType: Value(mealType), + description: Value(description), + proteinGrams: Value(proteinGrams), + calories: Value(calories), + carbsGrams: carbsGrams == null && nullToAbsent + ? const Value.absent() + : Value(carbsGrams), + fatGrams: fatGrams == null && nullToAbsent + ? const Value.absent() + : Value(fatGrams), + ); + } + + factory FoodLogRecord.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return FoodLogRecord( + id: serializer.fromJson(json['id']), + timestamp: serializer.fromJson(json['timestamp']), + mealType: serializer.fromJson(json['mealType']), + description: serializer.fromJson(json['description']), + proteinGrams: serializer.fromJson(json['proteinGrams']), + calories: serializer.fromJson(json['calories']), + carbsGrams: serializer.fromJson(json['carbsGrams']), + fatGrams: serializer.fromJson(json['fatGrams']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'timestamp': serializer.toJson(timestamp), + 'mealType': serializer.toJson(mealType), + 'description': serializer.toJson(description), + 'proteinGrams': serializer.toJson(proteinGrams), + 'calories': serializer.toJson(calories), + 'carbsGrams': serializer.toJson(carbsGrams), + 'fatGrams': serializer.toJson(fatGrams), + }; + } + + FoodLogRecord copyWith( + {int? id, + DateTime? timestamp, + String? mealType, + String? description, + double? proteinGrams, + int? calories, + Value carbsGrams = const Value.absent(), + Value fatGrams = const Value.absent()}) => + FoodLogRecord( + id: id ?? this.id, + timestamp: timestamp ?? this.timestamp, + mealType: mealType ?? this.mealType, + description: description ?? this.description, + proteinGrams: proteinGrams ?? this.proteinGrams, + calories: calories ?? this.calories, + carbsGrams: carbsGrams.present ? carbsGrams.value : this.carbsGrams, + fatGrams: fatGrams.present ? fatGrams.value : this.fatGrams, + ); + FoodLogRecord copyWithCompanion(FoodLogsTableCompanion data) { + return FoodLogRecord( + id: data.id.present ? data.id.value : this.id, + timestamp: data.timestamp.present ? data.timestamp.value : this.timestamp, + mealType: data.mealType.present ? data.mealType.value : this.mealType, + description: + data.description.present ? data.description.value : this.description, + proteinGrams: data.proteinGrams.present + ? data.proteinGrams.value + : this.proteinGrams, + calories: data.calories.present ? data.calories.value : this.calories, + carbsGrams: + data.carbsGrams.present ? data.carbsGrams.value : this.carbsGrams, + fatGrams: data.fatGrams.present ? data.fatGrams.value : this.fatGrams, + ); + } + + @override + String toString() { + return (StringBuffer('FoodLogRecord(') + ..write('id: $id, ') + ..write('timestamp: $timestamp, ') + ..write('mealType: $mealType, ') + ..write('description: $description, ') + ..write('proteinGrams: $proteinGrams, ') + ..write('calories: $calories, ') + ..write('carbsGrams: $carbsGrams, ') + ..write('fatGrams: $fatGrams') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, timestamp, mealType, description, + proteinGrams, calories, carbsGrams, fatGrams); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is FoodLogRecord && + other.id == this.id && + other.timestamp == this.timestamp && + other.mealType == this.mealType && + other.description == this.description && + other.proteinGrams == this.proteinGrams && + other.calories == this.calories && + other.carbsGrams == this.carbsGrams && + other.fatGrams == this.fatGrams); +} + +class FoodLogsTableCompanion extends UpdateCompanion { + final Value id; + final Value timestamp; + final Value mealType; + final Value description; + final Value proteinGrams; + final Value calories; + final Value carbsGrams; + final Value fatGrams; + const FoodLogsTableCompanion({ + this.id = const Value.absent(), + this.timestamp = const Value.absent(), + this.mealType = const Value.absent(), + this.description = const Value.absent(), + this.proteinGrams = const Value.absent(), + this.calories = const Value.absent(), + this.carbsGrams = const Value.absent(), + this.fatGrams = const Value.absent(), + }); + FoodLogsTableCompanion.insert({ + this.id = const Value.absent(), + required DateTime timestamp, + required String mealType, + required String description, + required double proteinGrams, + required int calories, + this.carbsGrams = const Value.absent(), + this.fatGrams = const Value.absent(), + }) : timestamp = Value(timestamp), + mealType = Value(mealType), + description = Value(description), + proteinGrams = Value(proteinGrams), + calories = Value(calories); + static Insertable custom({ + Expression? id, + Expression? timestamp, + Expression? mealType, + Expression? description, + Expression? proteinGrams, + Expression? calories, + Expression? carbsGrams, + Expression? fatGrams, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (timestamp != null) 'timestamp': timestamp, + if (mealType != null) 'meal_type': mealType, + if (description != null) 'description': description, + if (proteinGrams != null) 'protein_grams': proteinGrams, + if (calories != null) 'calories': calories, + if (carbsGrams != null) 'carbs_grams': carbsGrams, + if (fatGrams != null) 'fat_grams': fatGrams, + }); + } + + FoodLogsTableCompanion copyWith( + {Value? id, + Value? timestamp, + Value? mealType, + Value? description, + Value? proteinGrams, + Value? calories, + Value? carbsGrams, + Value? fatGrams}) { + return FoodLogsTableCompanion( + id: id ?? this.id, + timestamp: timestamp ?? this.timestamp, + mealType: mealType ?? this.mealType, + description: description ?? this.description, + proteinGrams: proteinGrams ?? this.proteinGrams, + calories: calories ?? this.calories, + carbsGrams: carbsGrams ?? this.carbsGrams, + fatGrams: fatGrams ?? this.fatGrams, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (timestamp.present) { + map['timestamp'] = Variable(timestamp.value); + } + if (mealType.present) { + map['meal_type'] = Variable(mealType.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (proteinGrams.present) { + map['protein_grams'] = Variable(proteinGrams.value); + } + if (calories.present) { + map['calories'] = Variable(calories.value); + } + if (carbsGrams.present) { + map['carbs_grams'] = Variable(carbsGrams.value); + } + if (fatGrams.present) { + map['fat_grams'] = Variable(fatGrams.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('FoodLogsTableCompanion(') + ..write('id: $id, ') + ..write('timestamp: $timestamp, ') + ..write('mealType: $mealType, ') + ..write('description: $description, ') + ..write('proteinGrams: $proteinGrams, ') + ..write('calories: $calories, ') + ..write('carbsGrams: $carbsGrams, ') + ..write('fatGrams: $fatGrams') + ..write(')')) + .toString(); + } +} + +class $HydrationLogsTableTable extends HydrationLogsTable + with TableInfo<$HydrationLogsTableTable, HydrationLogRecord> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $HydrationLogsTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + static const VerificationMeta _timestampMeta = + const VerificationMeta('timestamp'); + @override + late final GeneratedColumn timestamp = GeneratedColumn( + 'timestamp', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + static const VerificationMeta _volumeOzMeta = + const VerificationMeta('volumeOz'); + @override + late final GeneratedColumn volumeOz = GeneratedColumn( + 'volume_oz', aliasedName, false, + type: DriftSqlType.double, requiredDuringInsert: true); + @override + List get $columns => [id, timestamp, volumeOz]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'hydration_logs_table'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('timestamp')) { + context.handle(_timestampMeta, + timestamp.isAcceptableOrUnknown(data['timestamp']!, _timestampMeta)); + } else if (isInserting) { + context.missing(_timestampMeta); + } + if (data.containsKey('volume_oz')) { + context.handle(_volumeOzMeta, + volumeOz.isAcceptableOrUnknown(data['volume_oz']!, _volumeOzMeta)); + } else if (isInserting) { + context.missing(_volumeOzMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + HydrationLogRecord map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return HydrationLogRecord( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + timestamp: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}timestamp'])!, + volumeOz: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}volume_oz'])!, + ); + } + + @override + $HydrationLogsTableTable createAlias(String alias) { + return $HydrationLogsTableTable(attachedDatabase, alias); + } +} + +class HydrationLogRecord extends DataClass + implements Insertable { + final int id; + + /// When the fluid was consumed. + final DateTime timestamp; + + /// Consumed volume in fluid ounces. + final double volumeOz; + const HydrationLogRecord( + {required this.id, required this.timestamp, required this.volumeOz}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['timestamp'] = Variable(timestamp); + map['volume_oz'] = Variable(volumeOz); + return map; + } + + HydrationLogsTableCompanion toCompanion(bool nullToAbsent) { + return HydrationLogsTableCompanion( + id: Value(id), + timestamp: Value(timestamp), + volumeOz: Value(volumeOz), + ); + } + + factory HydrationLogRecord.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return HydrationLogRecord( + id: serializer.fromJson(json['id']), + timestamp: serializer.fromJson(json['timestamp']), + volumeOz: serializer.fromJson(json['volumeOz']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'timestamp': serializer.toJson(timestamp), + 'volumeOz': serializer.toJson(volumeOz), + }; + } + + HydrationLogRecord copyWith( + {int? id, DateTime? timestamp, double? volumeOz}) => + HydrationLogRecord( + id: id ?? this.id, + timestamp: timestamp ?? this.timestamp, + volumeOz: volumeOz ?? this.volumeOz, + ); + HydrationLogRecord copyWithCompanion(HydrationLogsTableCompanion data) { + return HydrationLogRecord( + id: data.id.present ? data.id.value : this.id, + timestamp: data.timestamp.present ? data.timestamp.value : this.timestamp, + volumeOz: data.volumeOz.present ? data.volumeOz.value : this.volumeOz, + ); + } + + @override + String toString() { + return (StringBuffer('HydrationLogRecord(') + ..write('id: $id, ') + ..write('timestamp: $timestamp, ') + ..write('volumeOz: $volumeOz') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, timestamp, volumeOz); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is HydrationLogRecord && + other.id == this.id && + other.timestamp == this.timestamp && + other.volumeOz == this.volumeOz); +} + +class HydrationLogsTableCompanion extends UpdateCompanion { + final Value id; + final Value timestamp; + final Value volumeOz; + const HydrationLogsTableCompanion({ + this.id = const Value.absent(), + this.timestamp = const Value.absent(), + this.volumeOz = const Value.absent(), + }); + HydrationLogsTableCompanion.insert({ + this.id = const Value.absent(), + required DateTime timestamp, + required double volumeOz, + }) : timestamp = Value(timestamp), + volumeOz = Value(volumeOz); + static Insertable custom({ + Expression? id, + Expression? timestamp, + Expression? volumeOz, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (timestamp != null) 'timestamp': timestamp, + if (volumeOz != null) 'volume_oz': volumeOz, + }); + } + + HydrationLogsTableCompanion copyWith( + {Value? id, Value? timestamp, Value? volumeOz}) { + return HydrationLogsTableCompanion( + id: id ?? this.id, + timestamp: timestamp ?? this.timestamp, + volumeOz: volumeOz ?? this.volumeOz, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (timestamp.present) { + map['timestamp'] = Variable(timestamp.value); + } + if (volumeOz.present) { + map['volume_oz'] = Variable(volumeOz.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('HydrationLogsTableCompanion(') + ..write('id: $id, ') + ..write('timestamp: $timestamp, ') + ..write('volumeOz: $volumeOz') + ..write(')')) + .toString(); + } +} + +class $BiometricLogsTableTable extends BiometricLogsTable + with TableInfo<$BiometricLogsTableTable, BiometricLogRecord> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $BiometricLogsTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + static const VerificationMeta _timestampMeta = + const VerificationMeta('timestamp'); + @override + late final GeneratedColumn timestamp = GeneratedColumn( + 'timestamp', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + static const VerificationMeta _weightLbsMeta = + const VerificationMeta('weightLbs'); + @override + late final GeneratedColumn weightLbs = GeneratedColumn( + 'weight_lbs', aliasedName, false, + type: DriftSqlType.double, requiredDuringInsert: true); + static const VerificationMeta _activeCaloriesBurnedMeta = + const VerificationMeta('activeCaloriesBurned'); + @override + late final GeneratedColumn activeCaloriesBurned = + GeneratedColumn('active_calories_burned', aliasedName, true, + type: DriftSqlType.double, requiredDuringInsert: false); + static const VerificationMeta _sourceMeta = const VerificationMeta('source'); + @override + late final GeneratedColumn source = GeneratedColumn( + 'source', aliasedName, false, + additionalChecks: + GeneratedColumn.checkTextLength(minTextLength: 1, maxTextLength: 32), + type: DriftSqlType.string, + requiredDuringInsert: true); + @override + List get $columns => + [id, timestamp, weightLbs, activeCaloriesBurned, source]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'biometric_logs_table'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('timestamp')) { + context.handle(_timestampMeta, + timestamp.isAcceptableOrUnknown(data['timestamp']!, _timestampMeta)); + } else if (isInserting) { + context.missing(_timestampMeta); + } + if (data.containsKey('weight_lbs')) { + context.handle(_weightLbsMeta, + weightLbs.isAcceptableOrUnknown(data['weight_lbs']!, _weightLbsMeta)); + } else if (isInserting) { + context.missing(_weightLbsMeta); + } + if (data.containsKey('active_calories_burned')) { + context.handle( + _activeCaloriesBurnedMeta, + activeCaloriesBurned.isAcceptableOrUnknown( + data['active_calories_burned']!, _activeCaloriesBurnedMeta)); + } + if (data.containsKey('source')) { + context.handle(_sourceMeta, + source.isAcceptableOrUnknown(data['source']!, _sourceMeta)); + } else if (isInserting) { + context.missing(_sourceMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + BiometricLogRecord map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return BiometricLogRecord( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + timestamp: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}timestamp'])!, + weightLbs: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}weight_lbs'])!, + activeCaloriesBurned: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}active_calories_burned']), + source: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}source'])!, + ); + } + + @override + $BiometricLogsTableTable createAlias(String alias) { + return $BiometricLogsTableTable(attachedDatabase, alias); + } +} + +class BiometricLogRecord extends DataClass + implements Insertable { + final int id; + + /// When the sample was recorded. + final DateTime timestamp; + + /// Body weight in pounds. + final double weightLbs; + + /// Active energy burned (kcal); `null` for weight-only samples. + final double? activeCaloriesBurned; + + /// Origin identifier (e.g. `appleHealth`, `manual`). + final String source; + const BiometricLogRecord( + {required this.id, + required this.timestamp, + required this.weightLbs, + this.activeCaloriesBurned, + required this.source}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['timestamp'] = Variable(timestamp); + map['weight_lbs'] = Variable(weightLbs); + if (!nullToAbsent || activeCaloriesBurned != null) { + map['active_calories_burned'] = Variable(activeCaloriesBurned); + } + map['source'] = Variable(source); + return map; + } + + BiometricLogsTableCompanion toCompanion(bool nullToAbsent) { + return BiometricLogsTableCompanion( + id: Value(id), + timestamp: Value(timestamp), + weightLbs: Value(weightLbs), + activeCaloriesBurned: activeCaloriesBurned == null && nullToAbsent + ? const Value.absent() + : Value(activeCaloriesBurned), + source: Value(source), + ); + } + + factory BiometricLogRecord.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return BiometricLogRecord( + id: serializer.fromJson(json['id']), + timestamp: serializer.fromJson(json['timestamp']), + weightLbs: serializer.fromJson(json['weightLbs']), + activeCaloriesBurned: + serializer.fromJson(json['activeCaloriesBurned']), + source: serializer.fromJson(json['source']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'timestamp': serializer.toJson(timestamp), + 'weightLbs': serializer.toJson(weightLbs), + 'activeCaloriesBurned': serializer.toJson(activeCaloriesBurned), + 'source': serializer.toJson(source), + }; + } + + BiometricLogRecord copyWith( + {int? id, + DateTime? timestamp, + double? weightLbs, + Value activeCaloriesBurned = const Value.absent(), + String? source}) => + BiometricLogRecord( + id: id ?? this.id, + timestamp: timestamp ?? this.timestamp, + weightLbs: weightLbs ?? this.weightLbs, + activeCaloriesBurned: activeCaloriesBurned.present + ? activeCaloriesBurned.value + : this.activeCaloriesBurned, + source: source ?? this.source, + ); + BiometricLogRecord copyWithCompanion(BiometricLogsTableCompanion data) { + return BiometricLogRecord( + id: data.id.present ? data.id.value : this.id, + timestamp: data.timestamp.present ? data.timestamp.value : this.timestamp, + weightLbs: data.weightLbs.present ? data.weightLbs.value : this.weightLbs, + activeCaloriesBurned: data.activeCaloriesBurned.present + ? data.activeCaloriesBurned.value + : this.activeCaloriesBurned, + source: data.source.present ? data.source.value : this.source, + ); + } + + @override + String toString() { + return (StringBuffer('BiometricLogRecord(') + ..write('id: $id, ') + ..write('timestamp: $timestamp, ') + ..write('weightLbs: $weightLbs, ') + ..write('activeCaloriesBurned: $activeCaloriesBurned, ') + ..write('source: $source') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, timestamp, weightLbs, activeCaloriesBurned, source); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is BiometricLogRecord && + other.id == this.id && + other.timestamp == this.timestamp && + other.weightLbs == this.weightLbs && + other.activeCaloriesBurned == this.activeCaloriesBurned && + other.source == this.source); +} + +class BiometricLogsTableCompanion extends UpdateCompanion { + final Value id; + final Value timestamp; + final Value weightLbs; + final Value activeCaloriesBurned; + final Value source; + const BiometricLogsTableCompanion({ + this.id = const Value.absent(), + this.timestamp = const Value.absent(), + this.weightLbs = const Value.absent(), + this.activeCaloriesBurned = const Value.absent(), + this.source = const Value.absent(), + }); + BiometricLogsTableCompanion.insert({ + this.id = const Value.absent(), + required DateTime timestamp, + required double weightLbs, + this.activeCaloriesBurned = const Value.absent(), + required String source, + }) : timestamp = Value(timestamp), + weightLbs = Value(weightLbs), + source = Value(source); + static Insertable custom({ + Expression? id, + Expression? timestamp, + Expression? weightLbs, + Expression? activeCaloriesBurned, + Expression? source, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (timestamp != null) 'timestamp': timestamp, + if (weightLbs != null) 'weight_lbs': weightLbs, + if (activeCaloriesBurned != null) + 'active_calories_burned': activeCaloriesBurned, + if (source != null) 'source': source, + }); + } + + BiometricLogsTableCompanion copyWith( + {Value? id, + Value? timestamp, + Value? weightLbs, + Value? activeCaloriesBurned, + Value? source}) { + return BiometricLogsTableCompanion( + id: id ?? this.id, + timestamp: timestamp ?? this.timestamp, + weightLbs: weightLbs ?? this.weightLbs, + activeCaloriesBurned: activeCaloriesBurned ?? this.activeCaloriesBurned, + source: source ?? this.source, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (timestamp.present) { + map['timestamp'] = Variable(timestamp.value); + } + if (weightLbs.present) { + map['weight_lbs'] = Variable(weightLbs.value); + } + if (activeCaloriesBurned.present) { + map['active_calories_burned'] = + Variable(activeCaloriesBurned.value); + } + if (source.present) { + map['source'] = Variable(source.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('BiometricLogsTableCompanion(') + ..write('id: $id, ') + ..write('timestamp: $timestamp, ') + ..write('weightLbs: $weightLbs, ') + ..write('activeCaloriesBurned: $activeCaloriesBurned, ') + ..write('source: $source') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); $AppDatabaseManager get managers => $AppDatabaseManager(this); late final $MedicationDosesTableTable medicationDosesTable = $MedicationDosesTableTable(this); + late final $FoodLogsTableTable foodLogsTable = $FoodLogsTableTable(this); + late final $HydrationLogsTableTable hydrationLogsTable = + $HydrationLogsTableTable(this); + late final $BiometricLogsTableTable biometricLogsTable = + $BiometricLogsTableTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @override - List get allSchemaEntities => [medicationDosesTable]; + List get allSchemaEntities => [ + medicationDosesTable, + foodLogsTable, + hydrationLogsTable, + biometricLogsTable + ]; } typedef $$MedicationDosesTableTableCreateCompanionBuilder @@ -645,10 +1651,557 @@ typedef $$MedicationDosesTableTableProcessedTableManager ), MedicationDoseRecord, PrefetchHooks Function()>; +typedef $$FoodLogsTableTableCreateCompanionBuilder = FoodLogsTableCompanion + Function({ + Value id, + required DateTime timestamp, + required String mealType, + required String description, + required double proteinGrams, + required int calories, + Value carbsGrams, + Value fatGrams, +}); +typedef $$FoodLogsTableTableUpdateCompanionBuilder = FoodLogsTableCompanion + Function({ + Value id, + Value timestamp, + Value mealType, + Value description, + Value proteinGrams, + Value calories, + Value carbsGrams, + Value fatGrams, +}); + +class $$FoodLogsTableTableFilterComposer + extends Composer<_$AppDatabase, $FoodLogsTableTable> { + $$FoodLogsTableTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get timestamp => $composableBuilder( + column: $table.timestamp, builder: (column) => ColumnFilters(column)); + + ColumnFilters get mealType => $composableBuilder( + column: $table.mealType, builder: (column) => ColumnFilters(column)); + + ColumnFilters get description => $composableBuilder( + column: $table.description, builder: (column) => ColumnFilters(column)); + + ColumnFilters get proteinGrams => $composableBuilder( + column: $table.proteinGrams, builder: (column) => ColumnFilters(column)); + + ColumnFilters get calories => $composableBuilder( + column: $table.calories, builder: (column) => ColumnFilters(column)); + + ColumnFilters get carbsGrams => $composableBuilder( + column: $table.carbsGrams, builder: (column) => ColumnFilters(column)); + + ColumnFilters get fatGrams => $composableBuilder( + column: $table.fatGrams, builder: (column) => ColumnFilters(column)); +} + +class $$FoodLogsTableTableOrderingComposer + extends Composer<_$AppDatabase, $FoodLogsTableTable> { + $$FoodLogsTableTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get timestamp => $composableBuilder( + column: $table.timestamp, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get mealType => $composableBuilder( + column: $table.mealType, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get description => $composableBuilder( + column: $table.description, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get proteinGrams => $composableBuilder( + column: $table.proteinGrams, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get calories => $composableBuilder( + column: $table.calories, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get carbsGrams => $composableBuilder( + column: $table.carbsGrams, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get fatGrams => $composableBuilder( + column: $table.fatGrams, builder: (column) => ColumnOrderings(column)); +} + +class $$FoodLogsTableTableAnnotationComposer + extends Composer<_$AppDatabase, $FoodLogsTableTable> { + $$FoodLogsTableTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get timestamp => + $composableBuilder(column: $table.timestamp, builder: (column) => column); + + GeneratedColumn get mealType => + $composableBuilder(column: $table.mealType, builder: (column) => column); + + GeneratedColumn get description => $composableBuilder( + column: $table.description, builder: (column) => column); + + GeneratedColumn get proteinGrams => $composableBuilder( + column: $table.proteinGrams, builder: (column) => column); + + GeneratedColumn get calories => + $composableBuilder(column: $table.calories, builder: (column) => column); + + GeneratedColumn get carbsGrams => $composableBuilder( + column: $table.carbsGrams, builder: (column) => column); + + GeneratedColumn get fatGrams => + $composableBuilder(column: $table.fatGrams, builder: (column) => column); +} + +class $$FoodLogsTableTableTableManager extends RootTableManager< + _$AppDatabase, + $FoodLogsTableTable, + FoodLogRecord, + $$FoodLogsTableTableFilterComposer, + $$FoodLogsTableTableOrderingComposer, + $$FoodLogsTableTableAnnotationComposer, + $$FoodLogsTableTableCreateCompanionBuilder, + $$FoodLogsTableTableUpdateCompanionBuilder, + ( + FoodLogRecord, + BaseReferences<_$AppDatabase, $FoodLogsTableTable, FoodLogRecord> + ), + FoodLogRecord, + PrefetchHooks Function()> { + $$FoodLogsTableTableTableManager(_$AppDatabase db, $FoodLogsTableTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$FoodLogsTableTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$FoodLogsTableTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$FoodLogsTableTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value timestamp = const Value.absent(), + Value mealType = const Value.absent(), + Value description = const Value.absent(), + Value proteinGrams = const Value.absent(), + Value calories = const Value.absent(), + Value carbsGrams = const Value.absent(), + Value fatGrams = const Value.absent(), + }) => + FoodLogsTableCompanion( + id: id, + timestamp: timestamp, + mealType: mealType, + description: description, + proteinGrams: proteinGrams, + calories: calories, + carbsGrams: carbsGrams, + fatGrams: fatGrams, + ), + createCompanionCallback: ({ + Value id = const Value.absent(), + required DateTime timestamp, + required String mealType, + required String description, + required double proteinGrams, + required int calories, + Value carbsGrams = const Value.absent(), + Value fatGrams = const Value.absent(), + }) => + FoodLogsTableCompanion.insert( + id: id, + timestamp: timestamp, + mealType: mealType, + description: description, + proteinGrams: proteinGrams, + calories: calories, + carbsGrams: carbsGrams, + fatGrams: fatGrams, + ), + withReferenceMapper: (p0) => p0 + .map((e) => ( + e.readTable<$FoodLogsTableTable, FoodLogRecord>(table), + BaseReferences<_$AppDatabase, $FoodLogsTableTable, + FoodLogRecord>(db, table, e) + )) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$FoodLogsTableTableProcessedTableManager = ProcessedTableManager< + _$AppDatabase, + $FoodLogsTableTable, + FoodLogRecord, + $$FoodLogsTableTableFilterComposer, + $$FoodLogsTableTableOrderingComposer, + $$FoodLogsTableTableAnnotationComposer, + $$FoodLogsTableTableCreateCompanionBuilder, + $$FoodLogsTableTableUpdateCompanionBuilder, + ( + FoodLogRecord, + BaseReferences<_$AppDatabase, $FoodLogsTableTable, FoodLogRecord> + ), + FoodLogRecord, + PrefetchHooks Function()>; +typedef $$HydrationLogsTableTableCreateCompanionBuilder + = HydrationLogsTableCompanion Function({ + Value id, + required DateTime timestamp, + required double volumeOz, +}); +typedef $$HydrationLogsTableTableUpdateCompanionBuilder + = HydrationLogsTableCompanion Function({ + Value id, + Value timestamp, + Value volumeOz, +}); + +class $$HydrationLogsTableTableFilterComposer + extends Composer<_$AppDatabase, $HydrationLogsTableTable> { + $$HydrationLogsTableTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get timestamp => $composableBuilder( + column: $table.timestamp, builder: (column) => ColumnFilters(column)); + + ColumnFilters get volumeOz => $composableBuilder( + column: $table.volumeOz, builder: (column) => ColumnFilters(column)); +} + +class $$HydrationLogsTableTableOrderingComposer + extends Composer<_$AppDatabase, $HydrationLogsTableTable> { + $$HydrationLogsTableTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get timestamp => $composableBuilder( + column: $table.timestamp, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get volumeOz => $composableBuilder( + column: $table.volumeOz, builder: (column) => ColumnOrderings(column)); +} + +class $$HydrationLogsTableTableAnnotationComposer + extends Composer<_$AppDatabase, $HydrationLogsTableTable> { + $$HydrationLogsTableTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get timestamp => + $composableBuilder(column: $table.timestamp, builder: (column) => column); + + GeneratedColumn get volumeOz => + $composableBuilder(column: $table.volumeOz, builder: (column) => column); +} + +class $$HydrationLogsTableTableTableManager extends RootTableManager< + _$AppDatabase, + $HydrationLogsTableTable, + HydrationLogRecord, + $$HydrationLogsTableTableFilterComposer, + $$HydrationLogsTableTableOrderingComposer, + $$HydrationLogsTableTableAnnotationComposer, + $$HydrationLogsTableTableCreateCompanionBuilder, + $$HydrationLogsTableTableUpdateCompanionBuilder, + ( + HydrationLogRecord, + BaseReferences<_$AppDatabase, $HydrationLogsTableTable, + HydrationLogRecord> + ), + HydrationLogRecord, + PrefetchHooks Function()> { + $$HydrationLogsTableTableTableManager( + _$AppDatabase db, $HydrationLogsTableTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$HydrationLogsTableTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$HydrationLogsTableTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$HydrationLogsTableTableAnnotationComposer( + $db: db, $table: table), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value timestamp = const Value.absent(), + Value volumeOz = const Value.absent(), + }) => + HydrationLogsTableCompanion( + id: id, + timestamp: timestamp, + volumeOz: volumeOz, + ), + createCompanionCallback: ({ + Value id = const Value.absent(), + required DateTime timestamp, + required double volumeOz, + }) => + HydrationLogsTableCompanion.insert( + id: id, + timestamp: timestamp, + volumeOz: volumeOz, + ), + withReferenceMapper: (p0) => p0 + .map((e) => ( + e.readTable<$HydrationLogsTableTable, HydrationLogRecord>( + table), + BaseReferences<_$AppDatabase, $HydrationLogsTableTable, + HydrationLogRecord>(db, table, e) + )) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$HydrationLogsTableTableProcessedTableManager = ProcessedTableManager< + _$AppDatabase, + $HydrationLogsTableTable, + HydrationLogRecord, + $$HydrationLogsTableTableFilterComposer, + $$HydrationLogsTableTableOrderingComposer, + $$HydrationLogsTableTableAnnotationComposer, + $$HydrationLogsTableTableCreateCompanionBuilder, + $$HydrationLogsTableTableUpdateCompanionBuilder, + ( + HydrationLogRecord, + BaseReferences<_$AppDatabase, $HydrationLogsTableTable, + HydrationLogRecord> + ), + HydrationLogRecord, + PrefetchHooks Function()>; +typedef $$BiometricLogsTableTableCreateCompanionBuilder + = BiometricLogsTableCompanion Function({ + Value id, + required DateTime timestamp, + required double weightLbs, + Value activeCaloriesBurned, + required String source, +}); +typedef $$BiometricLogsTableTableUpdateCompanionBuilder + = BiometricLogsTableCompanion Function({ + Value id, + Value timestamp, + Value weightLbs, + Value activeCaloriesBurned, + Value source, +}); + +class $$BiometricLogsTableTableFilterComposer + extends Composer<_$AppDatabase, $BiometricLogsTableTable> { + $$BiometricLogsTableTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get timestamp => $composableBuilder( + column: $table.timestamp, builder: (column) => ColumnFilters(column)); + + ColumnFilters get weightLbs => $composableBuilder( + column: $table.weightLbs, builder: (column) => ColumnFilters(column)); + + ColumnFilters get activeCaloriesBurned => $composableBuilder( + column: $table.activeCaloriesBurned, + builder: (column) => ColumnFilters(column)); + + ColumnFilters get source => $composableBuilder( + column: $table.source, builder: (column) => ColumnFilters(column)); +} + +class $$BiometricLogsTableTableOrderingComposer + extends Composer<_$AppDatabase, $BiometricLogsTableTable> { + $$BiometricLogsTableTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get timestamp => $composableBuilder( + column: $table.timestamp, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get weightLbs => $composableBuilder( + column: $table.weightLbs, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get activeCaloriesBurned => $composableBuilder( + column: $table.activeCaloriesBurned, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get source => $composableBuilder( + column: $table.source, builder: (column) => ColumnOrderings(column)); +} + +class $$BiometricLogsTableTableAnnotationComposer + extends Composer<_$AppDatabase, $BiometricLogsTableTable> { + $$BiometricLogsTableTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get timestamp => + $composableBuilder(column: $table.timestamp, builder: (column) => column); + + GeneratedColumn get weightLbs => + $composableBuilder(column: $table.weightLbs, builder: (column) => column); + + GeneratedColumn get activeCaloriesBurned => $composableBuilder( + column: $table.activeCaloriesBurned, builder: (column) => column); + + GeneratedColumn get source => + $composableBuilder(column: $table.source, builder: (column) => column); +} + +class $$BiometricLogsTableTableTableManager extends RootTableManager< + _$AppDatabase, + $BiometricLogsTableTable, + BiometricLogRecord, + $$BiometricLogsTableTableFilterComposer, + $$BiometricLogsTableTableOrderingComposer, + $$BiometricLogsTableTableAnnotationComposer, + $$BiometricLogsTableTableCreateCompanionBuilder, + $$BiometricLogsTableTableUpdateCompanionBuilder, + ( + BiometricLogRecord, + BaseReferences<_$AppDatabase, $BiometricLogsTableTable, + BiometricLogRecord> + ), + BiometricLogRecord, + PrefetchHooks Function()> { + $$BiometricLogsTableTableTableManager( + _$AppDatabase db, $BiometricLogsTableTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$BiometricLogsTableTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$BiometricLogsTableTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$BiometricLogsTableTableAnnotationComposer( + $db: db, $table: table), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value timestamp = const Value.absent(), + Value weightLbs = const Value.absent(), + Value activeCaloriesBurned = const Value.absent(), + Value source = const Value.absent(), + }) => + BiometricLogsTableCompanion( + id: id, + timestamp: timestamp, + weightLbs: weightLbs, + activeCaloriesBurned: activeCaloriesBurned, + source: source, + ), + createCompanionCallback: ({ + Value id = const Value.absent(), + required DateTime timestamp, + required double weightLbs, + Value activeCaloriesBurned = const Value.absent(), + required String source, + }) => + BiometricLogsTableCompanion.insert( + id: id, + timestamp: timestamp, + weightLbs: weightLbs, + activeCaloriesBurned: activeCaloriesBurned, + source: source, + ), + withReferenceMapper: (p0) => p0 + .map((e) => ( + e.readTable<$BiometricLogsTableTable, BiometricLogRecord>( + table), + BaseReferences<_$AppDatabase, $BiometricLogsTableTable, + BiometricLogRecord>(db, table, e) + )) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$BiometricLogsTableTableProcessedTableManager = ProcessedTableManager< + _$AppDatabase, + $BiometricLogsTableTable, + BiometricLogRecord, + $$BiometricLogsTableTableFilterComposer, + $$BiometricLogsTableTableOrderingComposer, + $$BiometricLogsTableTableAnnotationComposer, + $$BiometricLogsTableTableCreateCompanionBuilder, + $$BiometricLogsTableTableUpdateCompanionBuilder, + ( + BiometricLogRecord, + BaseReferences<_$AppDatabase, $BiometricLogsTableTable, + BiometricLogRecord> + ), + BiometricLogRecord, + PrefetchHooks Function()>; class $AppDatabaseManager { final _$AppDatabase _db; $AppDatabaseManager(this._db); $$MedicationDosesTableTableTableManager get medicationDosesTable => $$MedicationDosesTableTableTableManager(_db, _db.medicationDosesTable); + $$FoodLogsTableTableTableManager get foodLogsTable => + $$FoodLogsTableTableTableManager(_db, _db.foodLogsTable); + $$HydrationLogsTableTableTableManager get hydrationLogsTable => + $$HydrationLogsTableTableTableManager(_db, _db.hydrationLogsTable); + $$BiometricLogsTableTableTableManager get biometricLogsTable => + $$BiometricLogsTableTableTableManager(_db, _db.biometricLogsTable); } diff --git a/lib/core/platform/health_gateway.dart b/lib/core/platform/health_gateway.dart index feee046..b7679e7 100644 --- a/lib/core/platform/health_gateway.dart +++ b/lib/core/platform/health_gateway.dart @@ -1,58 +1,87 @@ -import 'dart:io'; -import 'package:flutter/foundation.dart'; +import 'health_gateway_fallback.dart' + if (dart.library.io) 'health_gateway_mobile.dart' as impl; +/// Native health ecosystem backing the current device. +/// +/// Kept in `core` (not the health_sync feature) so the platform layer never +/// imports feature code; the biometric controller maps this to the domain's +/// `BiometricSource` enum. +enum NativeHealthPlatform { + /// Apple HealthKit (iOS). + appleHealth, + + /// Google Health Connect (Android). + healthConnect, +} + +/// Scalar sample returned by the health gateway. +/// +/// Pure value type: [unit] is a display string (`lb`, `kcal`) — unit +/// normalization (kg → lb, joules → kcal) happens inside the gateway so +/// consumers only ever deal in pounds and Calories. class HealthMetric { - final DateTime timestamp; - final double value; - final String unit; - const HealthMetric({ required this.timestamp, required this.value, required this.unit, }); + + /// When the sample was recorded. + final DateTime timestamp; + + /// Normalized magnitude (pounds for weight, kilocalories for energy). + final double value; + + /// Unit of [value] for display (e.g. `lb`, `kcal`). + final String unit; } +/// Cross-platform boundary over the OS health ecosystems (architecture rule +/// 2/3: platform-specific packages never leak past this interface). +/// +/// * **Android / iOS:** implemented by `health_gateway_mobile.dart`, which +/// wraps the `health` package (Health Connect / HealthKit). +/// * **Windows / macOS / Web:** implemented by `health_gateway_fallback.dart`, +/// a manual-entry mode that never touches platform channels; consumers read +/// the drift-backed cache through `IHealthSyncRepository` instead. abstract class IHealthGateway { + /// Whether this device can read/write native health data. `false` on + /// Web/Windows/macOS, which signals the UI to offer manual entry. + bool get supportsNativeHealth; + + /// Ecosystem backing [supportsNativeHealth], or `null` in fallback mode. + NativeHealthPlatform? get nativePlatform; + + /// Requests read permission for weight and active energy; returns whether + /// access was granted. Always `false` in fallback mode. Future requestPermissions(); + + /// Weight samples in pounds for `[start, end)`, newest first. + /// + /// Returns an empty list (never throws) in fallback mode or when the OS + /// denies access. Future> fetchWeightHistory({ required DateTime start, required DateTime end, }); - Future> fetchWorkouts({ + + /// Active energy burned (kcal) samples for `[start, end)`, newest first. + /// + /// Returns an empty list (never throws) in fallback mode or when the OS + /// denies access. + Future> fetchActiveEnergyBurned({ required DateTime start, required DateTime end, }); + + /// Writes a weight measurement back to the native store; returns whether + /// the platform accepted it (always `false` in fallback mode). + Future writeWeight({ + required double weightLbs, + required DateTime timestamp, + }); } -class PlatformHealthGateway implements IHealthGateway { - @override - Future requestPermissions() async { - if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) { - return false; - } - return true; - } - - @override - Future> fetchWeightHistory({ - required DateTime start, - required DateTime end, - }) async { - if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) { - return []; - } - return []; - } - - @override - Future> fetchWorkouts({ - required DateTime start, - required DateTime end, - }) async { - if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) { - return []; - } - return []; - } -} +/// Platform-appropriate factory: native `health`-backed gateway on +/// Android/iOS, manual-entry fallback on Web/Windows/macOS. +IHealthGateway createHealthGateway() => impl.makeHealthGateway(); diff --git a/lib/core/platform/health_gateway_fallback.dart b/lib/core/platform/health_gateway_fallback.dart new file mode 100644 index 0000000..024d1e7 --- /dev/null +++ b/lib/core/platform/health_gateway_fallback.dart @@ -0,0 +1,45 @@ +import 'health_gateway.dart'; + +/// Conditional-import entry point used by `health_gateway.dart`. +IHealthGateway makeHealthGateway() => FallbackHealthGateway(); + +/// Graceful-degradation gateway for Web, Windows, and macOS (architecture +/// rule 3: desktop/web get read-only manual entry until cloud OAuth partners +/// land in a later phase). +/// +/// It never touches a platform channel, so unsupported hardware calls can +/// never crash: every read returns an empty list and every write reports +/// `false`. Consumers treat `supportsNativeHealth == false` as the signal to +/// open the manual-entry dialog and serve the drift-backed local cache +/// (`IHealthSyncRepository`) for reads. +class FallbackHealthGateway implements IHealthGateway { + @override + bool get supportsNativeHealth => false; + + @override + NativeHealthPlatform? get nativePlatform => null; + + @override + Future requestPermissions() async => false; + + @override + Future> fetchWeightHistory({ + required DateTime start, + required DateTime end, + }) async => + const []; + + @override + Future> fetchActiveEnergyBurned({ + required DateTime start, + required DateTime end, + }) async => + const []; + + @override + Future writeWeight({ + required double weightLbs, + required DateTime timestamp, + }) async => + false; +} \ No newline at end of file diff --git a/lib/core/platform/health_gateway_mobile.dart b/lib/core/platform/health_gateway_mobile.dart new file mode 100644 index 0000000..ebe961b --- /dev/null +++ b/lib/core/platform/health_gateway_mobile.dart @@ -0,0 +1,167 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:health/health.dart'; + +import 'health_gateway.dart'; + +/// Conditional-import entry point used by `health_gateway.dart`. +IHealthGateway makeHealthGateway() => MobileHealthGateway(); + +/// `package:health`-backed gateway for Android (Health Connect) and iOS +/// (HealthKit). +/// +/// Every call is guarded twice: statically by [supportsNativeHealth] (so +/// Web/Windows/macOS never reach a `MethodChannel` with no registered +/// plugin) and defensively by `try/catch` (so a missing Health Connect +/// installation or a denied permission degrades to an empty result instead +/// of crashing the caller). +class MobileHealthGateway implements IHealthGateway { + final Health _health = Health(); + Future? _configuration; + + @override + bool get supportsNativeHealth => + !kIsWeb && (Platform.isAndroid || Platform.isIOS); + + @override + NativeHealthPlatform? get nativePlatform { + if (!supportsNativeHealth) return null; + return Platform.isIOS + ? NativeHealthPlatform.appleHealth + : NativeHealthPlatform.healthConnect; + } + + /// `Health.configure()` resolves device info and must run exactly once + /// before any read/write call. + Future _configure() => _configuration ??= _health.configure(); + + @override + Future requestPermissions() async { + if (!supportsNativeHealth) return false; + try { + await _configure(); + return await _health.requestAuthorization( + const [HealthDataType.WEIGHT, HealthDataType.ACTIVE_ENERGY_BURNED], + permissions: const [HealthDataAccess.READ, HealthDataAccess.READ], + ); + } catch (error, stackTrace) { + debugPrintStack( + stackTrace: stackTrace, + label: 'health requestPermissions failed (non-fatal)', + ); + return false; + } + } + + @override + Future> fetchWeightHistory({ + required DateTime start, + required DateTime end, + }) async => + _fetch( + type: HealthDataType.WEIGHT, + start: start, + end: end, + normalize: _weightInLbs, + unit: 'lb', + ); + + @override + Future> fetchActiveEnergyBurned({ + required DateTime start, + required DateTime end, + }) async => + _fetch( + type: HealthDataType.ACTIVE_ENERGY_BURNED, + start: start, + end: end, + normalize: _energyInKcal, + unit: 'kcal', + ); + + @override + Future writeWeight({ + required double weightLbs, + required DateTime timestamp, + }) async { + if (!supportsNativeHealth) return false; + try { + await _configure(); + return await _health.writeHealthData( + value: weightLbs * 0.45359237, + unit: HealthDataUnit.KILOGRAM, + type: HealthDataType.WEIGHT, + startTime: timestamp, + endTime: timestamp, + recordingMethod: RecordingMethod.manual, + ); + } catch (error, stackTrace) { + debugPrintStack( + stackTrace: stackTrace, + label: 'health writeWeight failed (non-fatal)', + ); + return false; + } + } + + /// Shared query path: read [type] over `[start, end)`, keep only numeric + /// values, normalize each sample via [normalize], and sort newest-first. + Future> _fetch({ + required HealthDataType type, + required DateTime start, + required DateTime end, + required double Function(double raw, HealthDataUnit unit) normalize, + required String unit, + }) async { + if (!supportsNativeHealth) return const []; + try { + await _configure(); + final points = await _health.getHealthDataFromTypes( + types: [type], + startTime: start, + endTime: end, + ); + final metrics = []; + for (final point in points) { + final value = point.value; + if (value is! NumericHealthValue) continue; + metrics.add( + HealthMetric( + timestamp: point.dateFrom, + value: normalize(value.numericValue.toDouble(), point.unit), + unit: unit, + ), + ); + } + metrics.sort((a, b) => b.timestamp.compareTo(a.timestamp)); + return metrics; + } catch (error, stackTrace) { + // Denied permission, missing Health Connect, or unsupported hardware: + // degrade to "no data" so the UI can offer manual entry instead. + debugPrintStack( + stackTrace: stackTrace, + label: 'health $type query failed (non-fatal)', + ); + return const []; + } + } + + /// HealthKit / Health Connect report weight in kilograms by default; + /// normalize to pounds for the rest of the app. + double _weightInLbs(double raw, HealthDataUnit unit) { + switch (unit) { + case HealthDataUnit.POUND: + return raw; + case HealthDataUnit.GRAM: + return raw / 453.59237; + case HealthDataUnit.KILOGRAM: + default: + return raw * 2.2046226218; + } + } + + /// Active energy normally arrives in kilocalories; normalize joules too. + double _energyInKcal(double raw, HealthDataUnit unit) => + unit == HealthDataUnit.JOULE ? raw / 4.184 : raw; +} \ No newline at end of file diff --git a/lib/core/utils/date_time_utils.dart b/lib/core/utils/date_time_utils.dart index e9f5d8d..6cce9dc 100644 --- a/lib/core/utils/date_time_utils.dart +++ b/lib/core/utils/date_time_utils.dart @@ -19,6 +19,17 @@ String formatDateTime(DateTime time) { ' · $hour12:$minute $period'; } +/// Start of the local calendar day containing [time] (`00:00:00.000`). +DateTime startOfDay(DateTime time) => + DateTime(time.year, time.month, time.day); + +/// End of the local calendar day containing [time] — represented as the +/// exclusive upper bound `00:00:00.000` of the following day, so half-open +/// `[startOfDay, endOfDay)` ranges cover the whole day without an off-by-one +/// at midnight. +DateTime endOfDay(DateTime time) => + DateTime(time.year, time.month, time.day + 1); + /// Formats a coarse elapsed time relative to [now], e.g. `never`, `3 hours /// ago`, `5 days ago`. String formatRelativeTime(DateTime time, {DateTime? now}) { diff --git a/lib/features/health_sync/data/repositories/health_sync_repository.dart b/lib/features/health_sync/data/repositories/health_sync_repository.dart new file mode 100644 index 0000000..de50d41 --- /dev/null +++ b/lib/features/health_sync/data/repositories/health_sync_repository.dart @@ -0,0 +1,93 @@ +import 'package:drift/drift.dart'; + +import '../../../../core/database/app_database.dart'; +import '../../domain/entities/biometric_entry.dart'; +import '../../domain/repositories/health_sync_repository.dart'; + +/// Drift-backed implementation of [IHealthSyncRepository]. +/// +/// Owns the translation between persistence records ([BiometricLogRecord]) +/// and the pure-Dart [BiometricEntry] domain entity. The origin is persisted +/// as its stable enum name string so the database schema stays decoupled from +/// domain types. +class HealthSyncRepository implements IHealthSyncRepository { + HealthSyncRepository(this._database); + + final AppDatabase _database; + + @override + Stream> watchBiometrics({int limit = 500}) => _database + .watchBiometrics(limit: limit) + .map((records) => records.map(_mapRecord).toList(growable: false)); + + @override + Future> getBiometrics({ + int limit = 500, + DateTime? from, + DateTime? to, + }) async => + (await _database.getBiometrics(limit: limit, from: from, to: to)) + .map(_mapRecord) + .toList(growable: false); + + @override + Future saveBiometric(BiometricEntry entry) async { + final companion = _toCompanion(entry); + + final BiometricLogRecord record; + if (entry.id == null) { + record = await _database.insertBiometricLog(companion); + } else { + await _database.updateBiometricLog(entry.id!, companion); + record = await (_database.select(_database.biometricLogsTable) + ..where((t) => t.id.equals(entry.id!))) + .getSingle(); + } + return _mapRecord(record); + } + + @override + Future deleteBiometric(int id) => _database.deleteBiometricLog(id); + + @override + Future getLatestBiometric() async { + final record = await _database.latestBiometric(); + return record == null ? null : _mapRecord(record); + } + + @override + Future clearSyncedRange({ + required BiometricSource source, + required DateTime start, + required DateTime end, + }) => + _database.deleteBiometricsFromSourceWithin( + sourceName: source.persistenceKey, + start: start, + end: end, + ); + + /// Converts a persistence record into the domain entity. Unknown source + /// keys fall back to [BiometricSource.cloudSync] so legacy rows never break + /// the history view. + BiometricEntry _mapRecord(BiometricLogRecord record) => BiometricEntry( + id: record.id, + timestamp: record.timestamp, + weightLbs: record.weightLbs, + activeCaloriesBurned: record.activeCaloriesBurned, + source: BiometricSource.tryParse(record.source) ?? + BiometricSource.cloudSync, + ); + + /// Converts a domain entity into an insert/update companion. + BiometricLogsTableCompanion _toCompanion(BiometricEntry entry) => + BiometricLogsTableCompanion( + id: entry.id == null ? const Value.absent() : Value(entry.id!), + timestamp: Value(entry.timestamp), + weightLbs: Value(entry.weightLbs), + activeCaloriesBurned: entry.activeCaloriesBurned == null + ? const Value.absent() + : Value(entry.activeCaloriesBurned!), + source: Value(entry.source.persistenceKey), + ); +} \ No newline at end of file diff --git a/lib/features/health_sync/domain/entities/biometric_entry.dart b/lib/features/health_sync/domain/entities/biometric_entry.dart new file mode 100644 index 0000000..67493ab --- /dev/null +++ b/lib/features/health_sync/domain/entities/biometric_entry.dart @@ -0,0 +1,76 @@ +/// Origin of a biometric reading. +/// +/// Pure Dart (no framework imports); persisted as the enum [name] so the +/// database schema stays decoupled from domain types. +enum BiometricSource { + /// Imported from Apple HealthKit (iOS). + appleHealth, + + /// Imported from Google Health Connect (Android). + healthConnect, + + /// Typed in by the user (manual fallback on Web/Desktop or any platform). + manual, + + /// Imported from a third-party cloud partner (MyFitnessPal, Withings, ...). + cloudSync; + + /// Stable string persisted in the database. + String get persistenceKey => name; + + /// Parses a persisted key; returns `null` for unknown/legacy values. + static BiometricSource? tryParse(String? raw) { + if (raw == null) return null; + for (final source in values) { + if (source.persistenceKey == raw) return source; + } + return null; + } +} + +/// Immutable biometric sample: body weight plus optional active energy burn. +/// +/// Pure Dart (no framework imports) so the entity can be shared by the health +/// sync use cases, the drift-backed repository, and the Riverpod presentation +/// layer without leaking infrastructure concerns into the domain. +class BiometricEntry { + const BiometricEntry({ + this.id, + required this.timestamp, + required this.weightLbs, + this.activeCaloriesBurned, + required this.source, + }); + + /// Database primary key; `null` until the entry has been persisted. + final int? id; + + /// When the sample was recorded (measurement time, not import time). + final DateTime timestamp; + + /// Body weight in pounds. + final double weightLbs; + + /// Active energy burned (kcal) attributed to [timestamp]; `null` when the + /// sample carries only a weight measurement. + final double? activeCaloriesBurned; + + /// Where the sample originated. + final BiometricSource source; + + /// Copy helper preserving the database identifier. + BiometricEntry copyWith({ + int? id, + DateTime? timestamp, + double? weightLbs, + double? activeCaloriesBurned, + BiometricSource? source, + }) => + BiometricEntry( + id: id ?? this.id, + timestamp: timestamp ?? this.timestamp, + weightLbs: weightLbs ?? this.weightLbs, + activeCaloriesBurned: activeCaloriesBurned ?? this.activeCaloriesBurned, + source: source ?? this.source, + ); +} \ No newline at end of file diff --git a/lib/features/health_sync/domain/repositories/health_sync_repository.dart b/lib/features/health_sync/domain/repositories/health_sync_repository.dart new file mode 100644 index 0000000..225c1d7 --- /dev/null +++ b/lib/features/health_sync/domain/repositories/health_sync_repository.dart @@ -0,0 +1,38 @@ +import '../entities/biometric_entry.dart'; + +/// Persistence contract for the health-sync bounded context. +/// +/// The presentation layer depends only on this interface; the drift-backed +/// implementation lives in `data/repositories/` so widgets and controllers +/// remain testable with in-memory fakes. +abstract class IHealthSyncRepository { + /// Emits the biometric history (newest first) whenever it changes. + Stream> watchBiometrics({int limit = 500}); + + /// One-shot snapshot of the biometric history (newest first), optionally + /// filtered by the half-open interval `[from, to)`. + Future> getBiometrics({ + int limit = 500, + DateTime? from, + DateTime? to, + }); + + /// Persists [entry]; when [BiometricEntry.id] is `null` a new row is + /// inserted, otherwise the existing row is updated. Returns the stored entry + /// with its database-assigned identifier. + Future saveBiometric(BiometricEntry entry); + + /// Removes the biometric entry with [id], if present. + Future deleteBiometric(int id); + + /// Most recent entry, or `null` on an empty history. + Future getLatestBiometric(); + + /// Deletes every entry produced by [source] within `[start, end)` so a + /// re-sync replaces its previous import instead of duplicating rows. + Future clearSyncedRange({ + required BiometricSource source, + required DateTime start, + required DateTime end, + }); +} \ No newline at end of file diff --git a/lib/features/health_sync/presentation/controllers/biometric_controller.dart b/lib/features/health_sync/presentation/controllers/biometric_controller.dart new file mode 100644 index 0000000..6da1ccf --- /dev/null +++ b/lib/features/health_sync/presentation/controllers/biometric_controller.dart @@ -0,0 +1,388 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/database/app_database.dart'; +import '../../../../core/platform/health_gateway.dart'; +import '../../../../core/utils/date_time_utils.dart'; +import '../../data/repositories/health_sync_repository.dart'; +import '../../domain/entities/biometric_entry.dart'; +import '../../domain/repositories/health_sync_repository.dart'; + +/// Application-wide biometric repository (drift-backed). +/// +/// Overridable in widget/unit tests with an in-memory fake. +final healthSyncRepositoryProvider = Provider( + (ref) => HealthSyncRepository(ref.watch(appDatabaseProvider)), +); + +/// Cross-platform health boundary (HealthKit / Health Connect on mobile, +/// manual-entry fallback on Web/Windows/macOS). +/// +/// Overridable in tests with a fake gateway. +final healthGatewayProvider = Provider( + (ref) => createHealthGateway(), +); + +/// Immutable snapshot of the weight/biometric tracking state. +class BiometricState { + const BiometricState({ + required this.history, + required this.evaluatedAt, + this.isSyncing = false, + this.statusMessage, + this.error, + }); + + /// Full biometric history, newest first. + final List history; + + /// Timestamp at which the trend deltas were evaluated. + final DateTime evaluatedAt; + + /// Whether a native health sync is currently in flight. + final bool isSyncing; + + /// Human-readable outcome of the last sync/manual action (snackbar text). + final String? statusMessage; + + /// Most recent persistence/gateway failure, cleared on the next success. + final Object? error; + + /// Most recent biometric sample, or `null` on an empty history. + BiometricEntry? get latest => history.isEmpty ? null : history.first; + + /// Most recent recorded weight, or `null` on an empty history. + double? get latestWeightLbs => latest?.weightLbs; + + /// Most recent active-energy sample (kcal), or `null` when none exists. + double? get latestActiveCaloriesBurned { + for (final entry in history) { + final calories = entry.activeCaloriesBurned; + if (calories != null) return calories; + } + return null; + } + + /// Weight change (lbs) between the newest sample and the most recent + /// sample at least [days] old; `null` when the history does not reach back + /// that far. Negative values mean weight loss. + double? weightDeltaOver(int days, {DateTime? now}) { + final newest = latestWeightLbs; + if (newest == null) return null; + + final cutoff = (now ?? evaluatedAt).subtract(Duration(days: days)); + double? baseline; + for (final entry in history) { + if (!entry.timestamp.isAfter(cutoff)) { + // Newest-first ordering: the first entry at/before the cutoff is the + // closest baseline to it. + baseline = entry.weightLbs; + break; + } + } + if (baseline == null) return null; + return newest - baseline; + } + + /// Weight change over the trailing 7 days. + double? get weight7DayDelta => weightDeltaOver(7); + + /// Weight change over the trailing 30 days. + double? get weight30DayDelta => weightDeltaOver(30); + + BiometricState copyWith({ + List? history, + DateTime? evaluatedAt, + bool? isSyncing, + String? statusMessage, + Object? error, + bool clearError = false, + bool clearStatus = false, + }) => + BiometricState( + history: history ?? this.history, + evaluatedAt: evaluatedAt ?? this.evaluatedAt, + isSyncing: isSyncing ?? this.isSyncing, + statusMessage: clearStatus ? null : statusMessage ?? this.statusMessage, + error: clearError ? null : error ?? this.error, + ); +} + +/// Orchestrates the health-sync feature: persists manual biometric entries, +/// derives 7/30-day weight trends, and imports weight + active-energy data +/// through the platform [IHealthGateway] (HealthKit / Health Connect) with a +/// graceful manual-entry fallback on Web/Windows/macOS. +class BiometricController extends AsyncNotifier { + /// How far back a native sync imports samples. + static const Duration syncWindow = Duration(days: 90); + + /// Maximum number of history rows kept in the presentation state. + static const int historyLimit = 365; + + @override + Future build() => _load(); + + /// Persists a manually entered weight sample and refreshes trends. + Future logWeight({ + required double weightLbs, + DateTime? timestamp, + }) async { + if (weightLbs <= 0) return false; + + try { + final saved = await ref.read(healthSyncRepositoryProvider).saveBiometric( + BiometricEntry( + timestamp: timestamp ?? DateTime.now(), + weightLbs: weightLbs, + source: BiometricSource.manual, + ), + ); + await _refresh( + statusMessage: 'Logged ${saved.weightLbs.toStringAsFixed(1)} lb', + ); + return true; + } catch (error, stackTrace) { + _recordError(error, stackTrace, 'saveBiometric failed'); + return false; + } + } + + /// Removes a biometric sample by database [id]. + Future deleteEntry(int id) async { + try { + await ref.read(healthSyncRepositoryProvider).deleteBiometric(id); + await _refresh(); + return true; + } catch (error, stackTrace) { + _recordError(error, stackTrace, 'deleteBiometric failed'); + return false; + } + } + + /// Imports weight and active-energy samples from the native health store + /// over the trailing [window] and persists them idempotently (the previous + /// import for the same source/window is replaced, not duplicated). + /// + /// Returns `false` when the platform is unsupported (Web/Windows/macOS — + /// callers should open the manual-entry dialog instead), when permission + /// is denied, or when the sync throws; the reason is reflected in + /// [BiometricState.statusMessage]. + Future syncFromHealth({Duration window = syncWindow}) async { + final gateway = ref.read(healthGatewayProvider); + if (!gateway.supportsNativeHealth) { + await _refresh( + statusMessage: + 'Native health sync is unavailable here - use manual entry.', + ); + return false; + } + + _setState( + AsyncData( + state.valueOrNull?.copyWith(isSyncing: true) ?? + BiometricState( + history: const [], + evaluatedAt: DateTime.now(), + isSyncing: true, + ), + ), + ); + + try { + final granted = await gateway.requestPermissions(); + if (!granted) { + await _refresh( + statusMessage: 'Health permissions were denied.', + error: StateError('Health permissions denied'), + ); + return false; + } + + final now = DateTime.now(); + final start = now.subtract(window); + final weights = await gateway.fetchWeightHistory(start: start, end: now); + final energies = + await gateway.fetchActiveEnergyBurned(start: start, end: now); + + final source = switch (gateway.nativePlatform) { + NativeHealthPlatform.appleHealth => BiometricSource.appleHealth, + NativeHealthPlatform.healthConnect => BiometricSource.healthConnect, + null => BiometricSource.cloudSync, + }; + + final repository = ref.read(healthSyncRepositoryProvider); + // Idempotent re-sync: drop the previous import for this source/window. + await repository.clearSyncedRange( + source: source, + start: start, + end: now, + ); + + final savedWeights = []; + for (final metric in weights) { + savedWeights.add( + await repository.saveBiometric( + BiometricEntry( + timestamp: metric.timestamp, + weightLbs: metric.value, + source: source, + ), + ), + ); + } + + final energyDays = _groupEnergyByDay(energies); + var energyDaysStored = 0; + for (final day in energyDays.entries) { + final entry = _buildEnergyEntry( + dayStart: day.key, + kcal: day.value, + energies: energies, + savedWeights: savedWeights, + fallbackHistory: state.valueOrNull?.history ?? const [], + source: source, + ); + if (entry == null) continue; + await repository.saveBiometric(entry); + energyDaysStored++; + } + + await _refresh( + statusMessage: 'Synced ${weights.length} weight sample(s), ' + '$energyDaysStored day(s) of active energy.', + ); + return true; + } catch (error, stackTrace) { + _recordError(error, stackTrace, 'syncFromHealth failed'); + return false; + } + } + + /// Reloads the snapshot after a failed build (error-state retry action). + Future retry() async { + _setState(const AsyncLoading()); + try { + _setState(AsyncData(await _load())); + } catch (error, stackTrace) { + _setState(AsyncError(error, stackTrace)); + } + } + + /// Reloads the biometric history from the repository. + Future _load({String? statusMessage, Object? error}) async { + final history = await ref + .read(healthSyncRepositoryProvider) + .getBiometrics(limit: historyLimit); + return BiometricState( + history: history, + evaluatedAt: DateTime.now(), + statusMessage: statusMessage, + error: error, + ); + } + + /// Replaces the current state with a fresh snapshot; failures keep the + /// previous data so the UI never flashes empty after a rejected write. + Future _refresh({String? statusMessage, Object? error}) async { + try { + _setState(AsyncData( + await _load(statusMessage: statusMessage, error: error), + )); + } catch (loadError, stackTrace) { + _recordError(loadError, stackTrace, 'biometric snapshot refresh failed'); + } + } + + /// Attaches a non-destructive error to the last known good state (or as an + /// [AsyncError] when nothing has loaded yet). + void _recordError(Object error, StackTrace stackTrace, String label) { + debugPrintStack(stackTrace: stackTrace, label: '$label failed'); + final current = state.valueOrNull; + if (current == null) { + _setState(AsyncError(error, stackTrace)); + } else { + _setState(AsyncData(current.copyWith(error: error))); + } + } + + void _setState(AsyncValue next) => state = next; + + /// Sums active-energy samples per local calendar day (day-start anchors), + /// returned in chronological order. + Map _groupEnergyByDay(List energies) { + final perDay = {}; + for (final metric in energies) { + final day = startOfDay(metric.timestamp); + perDay[day] = (perDay[day] ?? 0) + metric.value; + } + final entries = perDay.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key)); + return Map.fromEntries(entries); + } + + /// Builds the entry carrying [kcal] for one day: attaches the total to + /// that day's synced weight sample when one exists, otherwise creates a + /// standalone sample anchored to the day's last energy reading using the + /// nearest prior known weight. Returns `null` when no weight is known yet. + BiometricEntry? _buildEnergyEntry({ + required DateTime dayStart, + required double kcal, + required List energies, + required List savedWeights, + required List fallbackHistory, + required BiometricSource source, + }) { + // Weight recorded on the same day (lists are newest-first). + BiometricEntry? sameDay; + for (final weight in savedWeights) { + if (startOfDay(weight.timestamp) == dayStart) { + sameDay ??= weight; + } + } + if (sameDay != null) { + return sameDay.copyWith(activeCaloriesBurned: kcal); + } + + // Nearest prior weight within the synced window. + final dayEnd = endOfDay(dayStart); + BiometricEntry? prior; + for (final weight in savedWeights) { + if (weight.timestamp.isBefore(dayEnd) && + (prior == null || weight.timestamp.isAfter(prior.timestamp))) { + prior = weight; + } + } + // Fall back to the cached local history (entries older than the window). + if (prior == null) { + for (final entry in fallbackHistory) { + if (entry.timestamp.isBefore(dayEnd) && + (prior == null || entry.timestamp.isAfter(prior.timestamp))) { + prior = entry; + } + } + } + if (prior == null) return null; + + // Anchor the sample to the day's last energy reading inside the window. + DateTime? anchor; + for (final metric in energies) { + if (startOfDay(metric.timestamp) == dayStart && + (anchor == null || metric.timestamp.isAfter(anchor))) { + anchor = metric.timestamp; + } + } + + return BiometricEntry( + timestamp: anchor ?? dayStart, + weightLbs: prior.weightLbs, + activeCaloriesBurned: kcal, + source: source, + ); + } +} + +/// Exposes the [BiometricController] to the widget tree. +final biometricControllerProvider = + AsyncNotifierProvider( + BiometricController.new, +); \ No newline at end of file diff --git a/lib/features/health_sync/presentation/widgets/log_weight_dialog.dart b/lib/features/health_sync/presentation/widgets/log_weight_dialog.dart new file mode 100644 index 0000000..360f723 --- /dev/null +++ b/lib/features/health_sync/presentation/widgets/log_weight_dialog.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../controllers/biometric_controller.dart'; + +/// Opens the manual weight-entry dialog (Web/Desktop fallback path, also +/// available on mobile for offline logging). +/// +/// Resolves to `true` when the sample was persisted, `false` when saving +/// failed, and `null` when the user dismissed the dialog. +Future showLogWeightDialog(BuildContext context) { + return showDialog( + context: context, + builder: (context) => const LogWeightDialog(), + ); +} + +/// Form for logging a body-weight measurement without native health access. +class LogWeightDialog extends ConsumerStatefulWidget { + const LogWeightDialog({super.key}); + + @override + ConsumerState createState() => _LogWeightDialogState(); +} + +class _LogWeightDialogState extends ConsumerState { + late final TextEditingController _weightController = TextEditingController(); + late DateTime _measuredAt = DateTime.now(); + + final GlobalKey _formKey = GlobalKey(); + bool _isSaving = false; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Log Weight'), + content: SizedBox( + width: 440, + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: _weightController, + decoration: const InputDecoration( + labelText: 'Weight (lbs)', + border: OutlineInputBorder(), + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + validator: (value) { + final parsed = double.tryParse((value ?? '').trim()); + if (parsed == null || parsed <= 0) { + return 'Enter a valid weight'; + } + return null; + }, + ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: _pickMeasuredAt, + icon: const Icon(Icons.calendar_today, size: 16), + label: Text('Measured: ${_formatDate(_measuredAt)}'), + ), + ], + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: _isSaving ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton.icon( + onPressed: _isSaving ? null : _save, + icon: _isSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.monitor_weight_outlined), + label: const Text('Save Weight'), + ), + ], + ); + } + + String _formatDate(DateTime time) { + const months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', + ]; + return '${months[time.month - 1]} ${time.day}, ${time.year}'; + } + + Future _pickMeasuredAt() async { + final now = DateTime.now(); + final pickedDate = await showDatePicker( + context: context, + initialDate: _measuredAt, + firstDate: DateTime(now.year - 5), + lastDate: now, + ); + if (pickedDate == null || !mounted) return; + + final pickedTime = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(_measuredAt), + ); + if (!mounted) return; + + setState(() { + _measuredAt = DateTime( + pickedDate.year, + pickedDate.month, + pickedDate.day, + pickedTime?.hour ?? _measuredAt.hour, + pickedTime?.minute ?? _measuredAt.minute, + ); + }); + } + + Future _save() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + + setState(() => _isSaving = true); + final saved = + await ref.read(biometricControllerProvider.notifier).logWeight( + weightLbs: double.parse(_weightController.text.trim()), + timestamp: _measuredAt, + ); + if (!mounted) return; + Navigator.of(context).pop(saved); + } + + @override + void dispose() { + _weightController.dispose(); + super.dispose(); + } +} \ No newline at end of file diff --git a/lib/features/nutrition/data/repositories/nutrition_repository.dart b/lib/features/nutrition/data/repositories/nutrition_repository.dart new file mode 100644 index 0000000..fc55155 --- /dev/null +++ b/lib/features/nutrition/data/repositories/nutrition_repository.dart @@ -0,0 +1,146 @@ +import 'package:drift/drift.dart'; + +import '../../../../core/database/app_database.dart'; +import '../../domain/entities/food_log_entry.dart'; +import '../../domain/entities/hydration_log_entry.dart'; +import '../../domain/entities/meal_type.dart'; +import '../../domain/repositories/nutrition_repository.dart'; + +/// Drift-backed implementation of [INutritionRepository]. +/// +/// Owns the translation between persistence records ([FoodLogRecord], +/// [HydrationLogRecord]) and the pure-Dart nutrition entities. The meal slot +/// is persisted as its stable enum name string so the database schema stays +/// decoupled from domain types. +class NutritionRepository implements INutritionRepository { + NutritionRepository(this._database); + + final AppDatabase _database; + + // --------------------------------------------------------------------------- + // Food diary + // --------------------------------------------------------------------------- + + @override + Stream> watchFoodLogs({int limit = 500}) => _database + .watchFoodLogs(limit: limit) + .map((records) => records.map(_mapFood).toList(growable: false)); + + @override + Future> getFoodLogs({ + int limit = 500, + DateTime? from, + DateTime? to, + }) async => + (await _database.getFoodLogs(limit: limit, from: from, to: to)) + .map(_mapFood) + .toList(growable: false); + + @override + Future saveFoodLog(FoodLogEntry entry) async { + final companion = _toFoodCompanion(entry); + + final FoodLogRecord record; + if (entry.id == null) { + record = await _database.insertFoodLog(companion); + } else { + await _database.updateFoodLog(entry.id!, companion); + record = await (_database.select(_database.foodLogsTable) + ..where((t) => t.id.equals(entry.id!))) + .getSingle(); + } + return _mapFood(record); + } + + @override + Future deleteFoodLog(int id) => _database.deleteFoodLog(id); + + // --------------------------------------------------------------------------- + // Hydration + // --------------------------------------------------------------------------- + + @override + Stream> watchHydrationLogs({int limit = 500}) => + _database.watchHydrationLogs(limit: limit).map( + (records) => records.map(_mapHydration).toList(growable: false), + ); + + @override + Future> getHydrationLogs({ + int limit = 500, + DateTime? from, + DateTime? to, + }) async => + (await _database.getHydrationLogs(limit: limit, from: from, to: to)) + .map(_mapHydration) + .toList(growable: false); + + @override + Future saveHydrationLog(HydrationLogEntry entry) async { + final companion = _toHydrationCompanion(entry); + + final HydrationLogRecord record; + if (entry.id == null) { + record = await _database.insertHydrationLog(companion); + } else { + await _database.updateHydrationLog(entry.id!, companion); + record = await (_database.select(_database.hydrationLogsTable) + ..where((t) => t.id.equals(entry.id!))) + .getSingle(); + } + return _mapHydration(record); + } + + @override + Future deleteHydrationLog(int id) => _database.deleteHydrationLog(id); + + // --------------------------------------------------------------------------- + // Mapping + // --------------------------------------------------------------------------- + + /// Converts a persistence record into the domain entity. Unknown meal keys + /// fall back to [MealType.snack] so legacy rows never break the diary. + FoodLogEntry _mapFood(FoodLogRecord record) => FoodLogEntry( + id: record.id, + timestamp: record.timestamp, + mealType: MealType.tryParse(record.mealType) ?? MealType.snack, + description: record.description, + proteinGrams: record.proteinGrams, + calories: record.calories, + carbsGrams: record.carbsGrams, + fatGrams: record.fatGrams, + ); + + /// Converts a domain entity into an insert/update companion. + FoodLogsTableCompanion _toFoodCompanion(FoodLogEntry entry) => + FoodLogsTableCompanion( + id: entry.id == null ? const Value.absent() : Value(entry.id!), + timestamp: Value(entry.timestamp), + mealType: Value(entry.mealType.persistenceKey), + description: Value(entry.description), + proteinGrams: Value(entry.proteinGrams), + calories: Value(entry.calories), + carbsGrams: entry.carbsGrams == null + ? const Value.absent() + : Value(entry.carbsGrams!), + fatGrams: entry.fatGrams == null + ? const Value.absent() + : Value(entry.fatGrams!), + ); + + /// Converts a persistence record into the domain entity. + HydrationLogEntry _mapHydration(HydrationLogRecord record) => + HydrationLogEntry( + id: record.id, + timestamp: record.timestamp, + volumeOz: record.volumeOz, + ); + + /// Converts a domain entity into an insert/update companion. + HydrationLogsTableCompanion _toHydrationCompanion(HydrationLogEntry entry) => + HydrationLogsTableCompanion( + id: entry.id == null ? const Value.absent() : Value(entry.id!), + timestamp: Value(entry.timestamp), + volumeOz: Value(entry.volumeOz), + ); +} \ No newline at end of file diff --git a/lib/features/nutrition/domain/entities/food_log_entry.dart b/lib/features/nutrition/domain/entities/food_log_entry.dart new file mode 100644 index 0000000..9c12b0a --- /dev/null +++ b/lib/features/nutrition/domain/entities/food_log_entry.dart @@ -0,0 +1,66 @@ +import 'meal_type.dart'; + +/// Immutable record of a single meal or snack intake. +/// +/// Pure Dart (no framework imports) so the entity can be shared by the +/// nutrition use cases, the drift-backed repository, and the Riverpod +/// presentation layer without leaking infrastructure concerns into the +/// domain. +class FoodLogEntry { + const FoodLogEntry({ + this.id, + required this.timestamp, + required this.mealType, + required this.description, + required this.proteinGrams, + required this.calories, + this.carbsGrams, + this.fatGrams, + }); + + /// Database primary key; `null` until the entry has been persisted. + final int? id; + + /// When the food was consumed. + final DateTime timestamp; + + /// Meal slot the entry belongs to (drives diary grouping). + final MealType mealType; + + /// Free-text description of the meal (e.g. "Greek yogurt with berries"). + final String description; + + /// Protein content in grams; the primary lean-mass tracking metric. + final double proteinGrams; + + /// Energy content in kilocalories. + final int calories; + + /// Carbohydrate content in grams; optional per the Phase 3 contract. + final double? carbsGrams; + + /// Fat content in grams; optional per the Phase 3 contract. + final double? fatGrams; + + /// Copy helper preserving the database identifier. + FoodLogEntry copyWith({ + int? id, + DateTime? timestamp, + MealType? mealType, + String? description, + double? proteinGrams, + int? calories, + double? carbsGrams, + double? fatGrams, + }) => + FoodLogEntry( + id: id ?? this.id, + timestamp: timestamp ?? this.timestamp, + mealType: mealType ?? this.mealType, + description: description ?? this.description, + proteinGrams: proteinGrams ?? this.proteinGrams, + calories: calories ?? this.calories, + carbsGrams: carbsGrams ?? this.carbsGrams, + fatGrams: fatGrams ?? this.fatGrams, + ); +} \ No newline at end of file diff --git a/lib/features/nutrition/domain/entities/hydration_log_entry.dart b/lib/features/nutrition/domain/entities/hydration_log_entry.dart new file mode 100644 index 0000000..aec751f --- /dev/null +++ b/lib/features/nutrition/domain/entities/hydration_log_entry.dart @@ -0,0 +1,30 @@ +/// Immutable record of a single fluid-intake log. +/// +/// Pure Dart (no framework imports) so the entity can be shared by the +/// nutrition use cases, the drift-backed repository, and the Riverpod +/// presentation layer without leaking infrastructure concerns into the +/// domain. +class HydrationLogEntry { + const HydrationLogEntry({ + this.id, + required this.timestamp, + required this.volumeOz, + }); + + /// Database primary key; `null` until the entry has been persisted. + final int? id; + + /// When the fluid was consumed. + final DateTime timestamp; + + /// Consumed volume in fluid ounces (US fl oz). + final double volumeOz; + + /// Copy helper preserving the database identifier. + HydrationLogEntry copyWith({int? id, DateTime? timestamp, double? volumeOz}) => + HydrationLogEntry( + id: id ?? this.id, + timestamp: timestamp ?? this.timestamp, + volumeOz: volumeOz ?? this.volumeOz, + ); +} \ No newline at end of file diff --git a/lib/features/nutrition/domain/entities/meal_type.dart b/lib/features/nutrition/domain/entities/meal_type.dart new file mode 100644 index 0000000..23e40e2 --- /dev/null +++ b/lib/features/nutrition/domain/entities/meal_type.dart @@ -0,0 +1,37 @@ +/// Meal slots used by the food diary. +/// +/// Pure Dart (no framework imports) so the enum can be shared by the domain +/// entities, the drift-backed repository (persisted via [persistenceKey]), +/// and the presentation layer. +enum MealType { + breakfast, + lunch, + dinner, + snack; + + /// Stable string persisted in the database; never changes across renames. + String get persistenceKey => name; + + /// Human-readable label for diary grouping headers. + String get displayName { + switch (this) { + case MealType.breakfast: + return 'Breakfast'; + case MealType.lunch: + return 'Lunch'; + case MealType.dinner: + return 'Dinner'; + case MealType.snack: + return 'Snack'; + } + } + + /// Parses a persisted key; returns `null` for unknown/legacy values. + static MealType? tryParse(String? raw) { + if (raw == null) return null; + for (final type in values) { + if (type.persistenceKey == raw) return type; + } + return null; + } +} \ No newline at end of file diff --git a/lib/features/nutrition/domain/repositories/nutrition_repository.dart b/lib/features/nutrition/domain/repositories/nutrition_repository.dart new file mode 100644 index 0000000..196783a --- /dev/null +++ b/lib/features/nutrition/domain/repositories/nutrition_repository.dart @@ -0,0 +1,57 @@ +import '../entities/food_log_entry.dart'; +import '../entities/hydration_log_entry.dart'; + +/// Persistence contract for the nutrition bounded context. +/// +/// The presentation layer depends only on this interface; the drift-backed +/// implementation lives in `data/repositories/` so widgets and controllers +/// remain testable with in-memory fakes. +abstract class INutritionRepository { + // --------------------------------------------------------------------------- + // Food diary + // --------------------------------------------------------------------------- + + /// Emits the food diary (newest first) whenever it changes. + Stream> watchFoodLogs({int limit = 500}); + + /// One-shot snapshot of the food diary (newest first). + /// + /// When [from]/[to] are provided only entries with `from <= timestamp < to` + /// are returned, which the controller uses to build the "today" view. + Future> getFoodLogs({ + int limit = 500, + DateTime? from, + DateTime? to, + }); + + /// Persists [entry]; when [FoodLogEntry.id] is `null` a new row is inserted, + /// otherwise the existing row is updated. Returns the stored entry with its + /// database-assigned identifier. + Future saveFoodLog(FoodLogEntry entry); + + /// Removes the food entry with [id], if present. + Future deleteFoodLog(int id); + + // --------------------------------------------------------------------------- + // Hydration + // --------------------------------------------------------------------------- + + /// Emits the hydration log (newest first) whenever it changes. + Stream> watchHydrationLogs({int limit = 500}); + + /// One-shot snapshot of the hydration log (newest first), optionally + /// filtered by the half-open interval `[from, to)`. + Future> getHydrationLogs({ + int limit = 500, + DateTime? from, + DateTime? to, + }); + + /// Persists [entry]; when [HydrationLogEntry.id] is `null` a new row is + /// inserted, otherwise the existing row is updated. Returns the stored entry + /// with its database-assigned identifier. + Future saveHydrationLog(HydrationLogEntry entry); + + /// Removes the hydration entry with [id], if present. + Future deleteHydrationLog(int id); +} \ No newline at end of file diff --git a/lib/features/nutrition/presentation/controllers/nutrition_controller.dart b/lib/features/nutrition/presentation/controllers/nutrition_controller.dart new file mode 100644 index 0000000..f0d45ed --- /dev/null +++ b/lib/features/nutrition/presentation/controllers/nutrition_controller.dart @@ -0,0 +1,251 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/database/app_database.dart'; +import '../../../../core/utils/date_time_utils.dart'; +import '../../data/repositories/nutrition_repository.dart'; +import '../../domain/entities/food_log_entry.dart'; +import '../../domain/entities/hydration_log_entry.dart'; +import '../../domain/entities/meal_type.dart'; +import '../../domain/repositories/nutrition_repository.dart'; + +/// Application-wide nutrition repository (drift-backed). +/// +/// Overridable in widget/unit tests with an in-memory fake. +final nutritionRepositoryProvider = Provider( + (ref) => NutritionRepository(ref.watch(appDatabaseProvider)), +); + +/// Immutable snapshot of the daily nutrition/hydration tracking state. +class NutritionState { + const NutritionState({ + required this.foodLogs, + required this.hydrationLogs, + required this.day, + required this.proteinTargetGrams, + required this.hydrationTargetOz, + }); + + /// Food entries logged on [day], newest first. + final List foodLogs; + + /// Hydration entries logged on [day], newest first. + final List hydrationLogs; + + /// Local calendar day the snapshot covers. + final DateTime day; + + /// Daily protein goal in grams (Phase 3 default: 100 g). + final double proteinTargetGrams; + + /// Daily hydration goal in fluid ounces (Phase 3 default: 96 oz). + final double hydrationTargetOz; + + /// Sum of [FoodLogEntry.proteinGrams] over [foodLogs]. + double get totalProteinGrams => + foodLogs.fold(0, (sum, entry) => sum + entry.proteinGrams); + + /// Sum of [FoodLogEntry.calories] over [foodLogs]. + int get totalCalories => + foodLogs.fold(0, (sum, entry) => sum + entry.calories); + + /// Sum of carbs over [foodLogs] when at least one entry recorded carbs. + double? get totalCarbsGrams { + double? total; + for (final entry in foodLogs) { + final carbs = entry.carbsGrams; + if (carbs == null) continue; + total = (total ?? 0) + carbs; + } + return total; + } + + /// Sum of fat over [foodLogs] when at least one entry recorded fat. + double? get totalFatGrams { + double? total; + for (final entry in foodLogs) { + final fat = entry.fatGrams; + if (fat == null) continue; + total = (total ?? 0) + fat; + } + return total; + } + + /// Sum of [HydrationLogEntry.volumeOz] over [hydrationLogs]. + double get totalWaterOz => + hydrationLogs.fold(0, (sum, entry) => sum + entry.volumeOz); + + /// Protein progress in `0..1+` (may exceed 1 when the goal is overshot). + double get proteinProgress => + proteinTargetGrams <= 0 ? 0 : totalProteinGrams / proteinTargetGrams; + + /// Hydration progress in `0..1+`. + double get hydrationProgress => + hydrationTargetOz <= 0 ? 0 : totalWaterOz / hydrationTargetOz; + + /// Today's meals grouped by slot in canonical order (breakfast → snack); + /// empty slots are omitted. + Map> get mealsByType { + final grouped = >{}; + for (final type in MealType.values) { + final entries = foodLogs.where((entry) => entry.mealType == type); + if (entries.isNotEmpty) grouped[type] = entries.toList(growable: false); + } + return grouped; + } +} + +/// Orchestrates the nutrition feature: loads today's food/hydration logs, +/// derives protein and water totals against the daily targets, and persists +/// quick-add / dialog entries. +class NutritionController extends AsyncNotifier { + /// Phase 3 default daily protein goal (lean-mass preservation target). + static const double defaultProteinTargetGrams = 100; + + /// Phase 3 default daily hydration goal. + static const double defaultHydrationTargetOz = 96; + + @override + Future build() async { + final repository = ref.watch(nutritionRepositoryProvider); + return _load(repository, day: DateTime.now()); + } + + /// Persists a meal entry and refreshes the daily snapshot. + /// + /// Returns whether the write succeeded; failures keep the last good state. + Future logFood({ + required MealType mealType, + required String description, + required double proteinGrams, + required int calories, + double? carbsGrams, + double? fatGrams, + DateTime? timestamp, + }) async { + final trimmed = description.trim(); + if (trimmed.isEmpty || proteinGrams < 0 || calories < 0) return false; + + try { + await ref.read(nutritionRepositoryProvider).saveFoodLog( + FoodLogEntry( + timestamp: timestamp ?? DateTime.now(), + mealType: mealType, + description: trimmed, + proteinGrams: proteinGrams, + calories: calories, + carbsGrams: carbsGrams, + fatGrams: fatGrams, + ), + ); + await _refresh(); + return true; + } catch (error, stackTrace) { + _recordError(error, stackTrace, 'saveFoodLog failed'); + return false; + } + } + + /// Removes a food entry by database [id]. + Future deleteFood(int id) async { + try { + await ref.read(nutritionRepositoryProvider).deleteFoodLog(id); + await _refresh(); + return true; + } catch (error, stackTrace) { + _recordError(error, stackTrace, 'deleteFoodLog failed'); + return false; + } + } + + /// Quick-adds [ounces] of water (e.g. the +8 / +16 / +24 oz buttons). + Future logWater(double ounces, {DateTime? timestamp}) async { + if (ounces <= 0) return false; + + try { + await ref.read(nutritionRepositoryProvider).saveHydrationLog( + HydrationLogEntry( + timestamp: timestamp ?? DateTime.now(), + volumeOz: ounces, + ), + ); + await _refresh(); + return true; + } catch (error, stackTrace) { + _recordError(error, stackTrace, 'saveHydrationLog failed'); + return false; + } + } + + /// Removes a hydration entry by database [id]. + Future deleteWater(int id) async { + try { + await ref.read(nutritionRepositoryProvider).deleteHydrationLog(id); + await _refresh(); + return true; + } catch (error, stackTrace) { + _recordError(error, stackTrace, 'deleteHydrationLog failed'); + return false; + } + } + + /// Reloads the snapshot after a failed build (error-state retry action). + Future retry() async { + state = const AsyncLoading(); + try { + state = AsyncData( + await _load(ref.read(nutritionRepositoryProvider), day: DateTime.now()), + ); + } catch (error, stackTrace) { + state = AsyncError(error, stackTrace); + } + } + + /// Reloads today's food and hydration logs from the repository. + Future _load( + INutritionRepository repository, { + required DateTime day, + }) async { + final from = startOfDay(day); + final to = endOfDay(day); + final foodLogs = await repository.getFoodLogs(from: from, to: to); + final hydrationLogs = await repository.getHydrationLogs(from: from, to: to); + return NutritionState( + foodLogs: foodLogs, + hydrationLogs: hydrationLogs, + day: day, + proteinTargetGrams: defaultProteinTargetGrams, + hydrationTargetOz: defaultHydrationTargetOz, + ); + } + + /// Replaces the current state with a fresh snapshot; failures keep the + /// previous data so the UI never flashes empty after a rejected write. + Future _refresh() async { + try { + state = AsyncData( + await _load(ref.read(nutritionRepositoryProvider), day: DateTime.now()), + ); + } catch (error, stackTrace) { + _recordError(error, stackTrace, 'nutrition snapshot refresh failed'); + } + } + + /// Attaches a non-destructive error to the last known good state (or as an + /// [AsyncError] when nothing has loaded yet). + void _recordError(Object error, StackTrace stackTrace, String label) { + debugPrintStack(stackTrace: stackTrace, label: '$label failed'); + final current = state.valueOrNull; + if (current == null) { + state = AsyncError(error, stackTrace); + } else { + state = AsyncData(current); + } + } +} + +/// Exposes the [NutritionController] to the widget tree. +final nutritionControllerProvider = + AsyncNotifierProvider( + NutritionController.new, +); \ No newline at end of file diff --git a/lib/features/nutrition/presentation/screens/nutrition_screen.dart b/lib/features/nutrition/presentation/screens/nutrition_screen.dart index a75be4d..a2ff83c 100644 --- a/lib/features/nutrition/presentation/screens/nutrition_screen.dart +++ b/lib/features/nutrition/presentation/screens/nutrition_screen.dart @@ -1,57 +1,648 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; -class NutritionScreen extends StatelessWidget { +import '../../../../core/platform/health_gateway.dart'; +import '../../../../core/utils/date_time_utils.dart'; +import '../../../health_sync/presentation/controllers/biometric_controller.dart'; +import '../../../health_sync/presentation/widgets/log_weight_dialog.dart'; +import '../../domain/entities/food_log_entry.dart'; +import '../../domain/entities/hydration_log_entry.dart'; +import '../../domain/entities/meal_type.dart'; +import '../controllers/nutrition_controller.dart'; +import '../widgets/log_food_dialog.dart'; + +/// GLP-1 nutrition dashboard: daily protein and hydration progress meters, +/// quick-add water buttons, a meal-grouped food diary, and the weight +/// tracker card with native health sync / manual-entry fallback. +class NutritionScreen extends ConsumerWidget { const NutritionScreen({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final nutrition = ref.watch(nutritionControllerProvider); + return Scaffold( appBar: AppBar(title: const Text('Nutrition & Hydration')), - body: ListView( - padding: const EdgeInsets.all(16.0), - children: const [ - Card( - child: Padding( - padding: EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Daily Protein Target', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), - ), - SizedBox(height: 8), - LinearProgressIndicator(value: 0.65, minHeight: 12), - SizedBox(height: 8), - Text('65g / 100g goal'), - ], - ), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: nutrition.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => _ErrorView( + error: error, + onRetry: () => + ref.read(nutritionControllerProvider.notifier).retry(), + ), + data: (state) => ListView( + padding: const EdgeInsets.all(16), + children: [ + _ProteinCard(state: state), + const SizedBox(height: 16), + _HydrationCard(state: state), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: () => showLogFoodDialog(context), + icon: const Icon(Icons.add_circle_outline), + label: const Text('Log Food'), + ), + const SizedBox(height: 16), + const _WeightTrackerCard(), + const SizedBox(height: 24), + _FoodDiary(state: state), + const SizedBox(height: 24), + ], ), ), - SizedBox(height: 16), - Card( - child: Padding( - padding: EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Daily Hydration', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), - ), - SizedBox(height: 8), - LinearProgressIndicator( - value: 0.5, - minHeight: 12, - color: Colors.blue, - ), - SizedBox(height: 8), - Text('48 oz / 96 oz goal'), - ], - ), - ), - ), - ], + ), + ), + ); + } +} + +/// Interactive protein meter: today's total vs. the 100 g daily target plus +/// calorie/carb/fat roll-ups. +class _ProteinCard extends StatelessWidget { + const _ProteinCard({required this.state}); + + final NutritionState state; + + @override + Widget build(BuildContext context) { + final progress = state.proteinProgress.clamp(0.0, 1.0).toDouble(); + final reachedGoal = state.totalProteinGrams >= state.proteinTargetGrams; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Daily Protein Target', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + if (reachedGoal) + Chip( + label: const Text('Goal met'), + visualDensity: VisualDensity.compact, + backgroundColor: + Theme.of(context).colorScheme.secondaryContainer, + ), + ], + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: LinearProgressIndicator( + value: progress, + minHeight: 12, + color: reachedGoal + ? Colors.green + : Theme.of(context).colorScheme.primary, + backgroundColor: + Theme.of(context).colorScheme.surfaceContainerHighest, + ), + ), + const SizedBox(height: 8), + Text( + '${state.totalProteinGrams.toStringAsFixed(1)}g / ' + '${state.proteinTargetGrams.toStringAsFixed(0)}g goal', + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 4), + Text( + _macroSummary(state), + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: Theme.of(context).colorScheme.outline), + ), + ], + ), + ), + ); + } + + /// `1,240 kcal · Carbs 40g · Fat 20g` (macro segments only when logged). + String _macroSummary(NutritionState state) { + final parts = ['${state.totalCalories} kcal']; + final carbs = state.totalCarbsGrams; + final fat = state.totalFatGrams; + if (carbs != null) parts.add('Carbs ${carbs.toStringAsFixed(0)}g'); + if (fat != null) parts.add('Fat ${fat.toStringAsFixed(0)}g'); + return parts.join(' · '); + } +} + +/// Interactive hydration meter with quick-add buttons (+8 / +16 / +24 oz) +/// and today's fluid log (each entry deletable). +class _HydrationCard extends ConsumerWidget { + const _HydrationCard({required this.state}); + + final NutritionState state; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final progress = state.hydrationProgress.clamp(0.0, 1.0).toDouble(); + final reachedGoal = state.totalWaterOz >= state.hydrationTargetOz; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Daily Hydration', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: LinearProgressIndicator( + value: progress, + minHeight: 12, + color: reachedGoal ? Colors.teal : Colors.blue, + backgroundColor: + Theme.of(context).colorScheme.surfaceContainerHighest, + ), + ), + const SizedBox(height: 8), + Text( + '${state.totalWaterOz.toStringAsFixed(0)} oz / ' + '${state.hydrationTargetOz.toStringAsFixed(0)} oz goal', + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final increment in [8.0, 16.0, 24.0]) + FilledButton.tonalIcon( + onPressed: () => _quickAdd(ref, increment), + icon: const Icon(Icons.water_drop, size: 16), + label: Text('+$increment oz'), + ), + ], + ), + if (state.hydrationLogs.isNotEmpty) ...[ + const SizedBox(height: 8), + for (final entry in state.hydrationLogs) + _HydrationTile( + entry: entry, + onDelete: () => _confirmDelete(context, ref, entry), + ), + ], + ], + ), + ), + ); + } + + Future _quickAdd(WidgetRef ref, double ounces) => + ref.read(nutritionControllerProvider.notifier).logWater(ounces); + + Future _confirmDelete( + BuildContext context, + WidgetRef ref, + HydrationLogEntry entry, + ) async { + final id = entry.id; + if (id == null) return; + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete water entry?'), + content: Text( + '${entry.volumeOz.toStringAsFixed(0)} oz · ' + '${formatDateTime(entry.timestamp)}', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Delete'), + ), + ], + ), + ); + if (confirmed != true) return; + + await ref.read(nutritionControllerProvider.notifier).deleteWater(id); + } +} + +/// Single fluid-log row: volume, time, and a delete action. +class _HydrationTile extends StatelessWidget { + const _HydrationTile({required this.entry, required this.onDelete}); + + final HydrationLogEntry entry; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.water_drop, color: Colors.blue), + title: Text('${entry.volumeOz.toStringAsFixed(0)} oz'), + subtitle: Text(formatDateTime(entry.timestamp)), + trailing: entry.id == null + ? null + : IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: 'Delete', + onPressed: onDelete, + ), + ); + } +} + +/// Weight tracker card: latest weight, 7/30-day trends, and the +/// "Log Weight / Sync" action. +/// +/// On Android/iOS the button triggers the native [IHealthGateway] sync +/// (HealthKit / Health Connect); on Windows/macOS/Web it opens the manual +/// entry dialog instead, keeping feature parity without unsupported +/// hardware calls. +class _WeightTrackerCard extends ConsumerWidget { + const _WeightTrackerCard(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final biometric = ref.watch(biometricControllerProvider); + final supported = ref.watch(healthGatewayProvider).supportsNativeHealth; + + // Surface sync/manual outcomes as snackbars whenever the status changes. + ref.listen(biometricControllerProvider, (previous, next) { + final message = next.valueOrNull?.statusMessage; + if (message == null || message == previous?.valueOrNull?.statusMessage) { + return; + } + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(message))); + }); + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: biometric.when( + loading: () => const SizedBox( + height: 120, + child: Center(child: CircularProgressIndicator()), + ), + error: (error, _) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _cardTitle(context), + const SizedBox(height: 8), + Text('Could not load weight history: $error'), + const SizedBox(height: 8), + FilledButton( + onPressed: () => + ref.read(biometricControllerProvider.notifier).retry(), + child: const Text('Retry'), + ), + ], + ), + data: (state) => _content(context, ref, state, supported), + ), + ), + ); + } + + Widget _content( + BuildContext context, + WidgetRef ref, + BiometricState state, + bool supported, + ) { + final latest = state.latestWeightLbs; + final colorScheme = Theme.of(context).colorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _cardTitle(context), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + latest == null ? '-- lb' : '${latest.toStringAsFixed(1)} lb', + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(width: 8), + if (state.latest?.timestamp != null) + Text( + 'as of ${formatDateTime(state.latest!.timestamp)}', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: colorScheme.outline), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 16, + runSpacing: 4, + children: [ + _trendChip(context, '7-day trend', state.weight7DayDelta), + _trendChip(context, '30-day trend', state.weight30DayDelta), + if (state.latestActiveCaloriesBurned != null) + Text( + 'Active burn: ' + '${state.latestActiveCaloriesBurned!.toStringAsFixed(0)} kcal', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + FilledButton.icon( + onPressed: state.isSyncing + ? null + : () => _logWeightOrSync(context, ref), + icon: state.isSyncing + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + supported ? Icons.sync : Icons.monitor_weight_outlined, + size: 18, + ), + label: Text(state.isSyncing ? 'Syncing...' : 'Log Weight / Sync'), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () => showLogWeightDialog(context), + icon: const Icon(Icons.edit_outlined, size: 18), + label: const Text('Manual'), + ), + ], + ), + ], + ); + } + + Widget _cardTitle(BuildContext context) => Text( + 'Weight Tracker', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ); + + /// `7-day trend: ▼ 1.2 lb` — green for loss, orange for gain, neutral when + /// the history does not reach back far enough. + Widget _trendChip(BuildContext context, String label, double? delta) { + final color = delta == null + ? Theme.of(context).colorScheme.outline + : delta < 0 + ? Colors.green + : delta > 0 + ? Colors.orange + : Theme.of(context).colorScheme.outline; + final text = delta == null + ? '—' + : '${delta < 0 ? '▼' : delta > 0 ? '▲' : '·'} ' + '${delta.abs().toStringAsFixed(1)} lb'; + + return Text.rich( + TextSpan( + children: [ + TextSpan(text: '$label: '), + TextSpan( + text: text, + style: TextStyle(color: color, fontWeight: FontWeight.bold), + ), + ], + ), + style: Theme.of(context).textTheme.bodyMedium, + ); + } + + /// Native platforms run the health sync; Web/Windows/macOS open the manual + /// entry dialog (the gateway reports `supportsNativeHealth == false`). + Future _logWeightOrSync(BuildContext context, WidgetRef ref) async { + final supported = ref.read(healthGatewayProvider).supportsNativeHealth; + if (!supported) { + await showLogWeightDialog(context); + return; + } + await ref.read(biometricControllerProvider.notifier).syncFromHealth(); + } +} + +/// Today's food diary grouped by meal slot (breakfast → snack) with +/// per-entry delete actions. +class _FoodDiary extends ConsumerWidget { + const _FoodDiary({required this.state}); + + final NutritionState state; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final grouped = state.mealsByType; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Food Diary', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + if (grouped.isEmpty) + const Card( + child: Padding( + padding: EdgeInsets.all(16), + child: Text( + 'No meals logged today yet. Tap "Log Food" to add your ' + 'first entry.', + ), + ), + ) + else + for (final meal in grouped.entries) ...[ + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: Builder( + builder: (context) { + final mealProtein = meal.value.fold( + 0, + (sum, entry) => sum + entry.proteinGrams, + ); + return Row( + children: [ + Icon(_mealIcon(meal.key), size: 18), + const SizedBox(width: 8), + Text( + meal.key.displayName, + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(width: 8), + Text( + '${mealProtein.toStringAsFixed(0)}g protein', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.outline, + ), + ), + ], + ); + }, + ), + ), + for (final entry in meal.value) + _FoodTile(entry: entry, mealType: meal.key), + ], + ], + ); + } + + IconData _mealIcon(MealType type) { + switch (type) { + case MealType.breakfast: + return Icons.free_breakfast; + case MealType.lunch: + return Icons.lunch_dining; + case MealType.dinner: + return Icons.dinner_dining; + case MealType.snack: + return Icons.cookie; + } + } +} + +/// Single diary row: description, timestamp, macro summary, delete action. +class _FoodTile extends ConsumerWidget { + const _FoodTile({required this.entry, required this.mealType}); + + final FoodLogEntry entry; + final MealType mealType; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text(entry.description), + subtitle: Text(_subtitle()), + trailing: entry.id == null + ? null + : PopupMenuButton( + onSelected: (value) { + if (value == 'delete') _confirmDelete(context, ref); + }, + itemBuilder: (context) => const [ + PopupMenuItem(value: 'delete', child: Text('Delete')), + ], + ), + ); + } + + /// `Oct 16, 2026 · 8:05 AM · 30g protein · 320 kcal · ...` + String _subtitle() { + final parts = [ + formatDateTime(entry.timestamp), + '${entry.proteinGrams.toStringAsFixed(1)}g protein', + '${entry.calories} kcal', + ]; + final carbs = entry.carbsGrams; + final fat = entry.fatGrams; + if (carbs != null) parts.add('C ${carbs.toStringAsFixed(0)}g'); + if (fat != null) parts.add('F ${fat.toStringAsFixed(0)}g'); + return parts.join(' · '); + } + + Future _confirmDelete(BuildContext context, WidgetRef ref) async { + final id = entry.id; + if (id == null) return; + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete meal entry?'), + content: Text('${entry.description} · ${mealType.displayName}'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Delete'), + ), + ], + ), + ); + if (confirmed != true) return; + + await ref.read(nutritionControllerProvider.notifier).deleteFood(id); + } +} + +/// Full-screen failure state shown when today's logs could not be loaded. +class _ErrorView extends StatelessWidget { + const _ErrorView({required this.error, required this.onRetry}); + + final Object error; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.red), + const SizedBox(height: 12), + Text( + 'Could not load your nutrition data.', + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text('$error', textAlign: TextAlign.center), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), ), ); } diff --git a/lib/features/nutrition/presentation/widgets/log_food_dialog.dart b/lib/features/nutrition/presentation/widgets/log_food_dialog.dart new file mode 100644 index 0000000..95e09a2 --- /dev/null +++ b/lib/features/nutrition/presentation/widgets/log_food_dialog.dart @@ -0,0 +1,230 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../domain/entities/meal_type.dart'; +import '../controllers/nutrition_controller.dart'; + +/// Opens the interactive food logging dialog. +/// +/// Resolves to `true` when the meal was persisted, `false` when saving +/// failed, and `null` when the user dismissed the dialog. +Future showLogFoodDialog( + BuildContext context, { + MealType initialMealType = MealType.breakfast, +}) { + return showDialog( + context: context, + builder: (context) => LogFoodDialog(initialMealType: initialMealType), + ); +} + +/// Form for logging a meal: slot, description, protein, calories, and +/// optional carbohydrate/fat macros. +class LogFoodDialog extends ConsumerStatefulWidget { + const LogFoodDialog({ + super.key, + this.initialMealType = MealType.breakfast, + }); + + /// Meal slot pre-selected in the dropdown. + final MealType initialMealType; + + @override + ConsumerState createState() => _LogFoodDialogState(); +} + +class _LogFoodDialogState extends ConsumerState { + late MealType _mealType = widget.initialMealType; + + late final TextEditingController _descriptionController = + TextEditingController(); + late final TextEditingController _proteinController = + TextEditingController(); + late final TextEditingController _caloriesController = + TextEditingController(); + late final TextEditingController _carbsController = TextEditingController(); + late final TextEditingController _fatController = TextEditingController(); + + final GlobalKey _formKey = GlobalKey(); + bool _isSaving = false; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Log Food'), + content: SizedBox( + width: 440, + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DropdownButtonFormField( + initialValue: _mealType, + decoration: const InputDecoration( + labelText: 'Meal', + border: OutlineInputBorder(), + ), + items: [ + for (final type in MealType.values) + DropdownMenuItem( + value: type, + child: Text(type.displayName), + ), + ], + onChanged: (type) { + if (type == null) return; + setState(() => _mealType = type); + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _descriptionController, + decoration: const InputDecoration( + labelText: 'Description', + hintText: 'e.g. Greek yogurt with berries', + border: OutlineInputBorder(), + ), + textCapitalization: TextCapitalization.sentences, + validator: (value) => (value == null || value.trim().isEmpty) + ? 'Enter a description' + : null, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _proteinController, + decoration: const InputDecoration( + labelText: 'Protein (g)', + border: OutlineInputBorder(), + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + validator: _positiveDecimalValidator('protein'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: _caloriesController, + decoration: const InputDecoration( + labelText: 'Calories', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + validator: _nonNegativeIntValidator('calories'), + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _carbsController, + decoration: const InputDecoration( + labelText: 'Carbs (g, optional)', + border: OutlineInputBorder(), + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + validator: _optionalDecimalValidator('carbs'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: _fatController, + decoration: const InputDecoration( + labelText: 'Fat (g, optional)', + border: OutlineInputBorder(), + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + validator: _optionalDecimalValidator('fat'), + ), + ), + ], + ), + ], + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: _isSaving ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton.icon( + onPressed: _isSaving ? null : _save, + icon: _isSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.restaurant_menu), + label: const Text('Log Food'), + ), + ], + ); + } + + String? Function(String?) _positiveDecimalValidator(String field) => + (value) { + final parsed = double.tryParse((value ?? '').trim()); + if (parsed == null || parsed < 0) return 'Enter a valid $field'; + return null; + }; + + String? Function(String?) _nonNegativeIntValidator(String field) => + (value) { + final parsed = int.tryParse((value ?? '').trim()); + if (parsed == null || parsed < 0) return 'Enter a valid $field'; + return null; + }; + + String? Function(String?) _optionalDecimalValidator(String field) => + (value) { + final raw = (value ?? '').trim(); + if (raw.isEmpty) return null; + final parsed = double.tryParse(raw); + if (parsed == null || parsed < 0) return 'Enter a valid $field'; + return null; + }; + + Future _save() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + + setState(() => _isSaving = true); + final saved = await ref.read(nutritionControllerProvider.notifier).logFood( + mealType: _mealType, + description: _descriptionController.text, + proteinGrams: double.parse(_proteinController.text.trim()), + calories: int.parse(_caloriesController.text.trim()), + carbsGrams: double.tryParse(_carbsController.text.trim()), + fatGrams: double.tryParse(_fatController.text.trim()), + ); + if (!mounted) return; + Navigator.of(context).pop(saved); + } + + @override + void dispose() { + _descriptionController.dispose(); + _proteinController.dispose(); + _caloriesController.dispose(); + _carbsController.dispose(); + _fatController.dispose(); + super.dispose(); + } +} \ No newline at end of file diff --git a/test/features/health_sync/presentation/biometric_controller_test.dart b/test/features/health_sync/presentation/biometric_controller_test.dart new file mode 100644 index 0000000..af5939a --- /dev/null +++ b/test/features/health_sync/presentation/biometric_controller_test.dart @@ -0,0 +1,346 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:glp1_companion/core/platform/health_gateway.dart'; +import 'package:glp1_companion/features/health_sync/domain/entities/biometric_entry.dart'; +import 'package:glp1_companion/features/health_sync/domain/repositories/health_sync_repository.dart'; +import 'package:glp1_companion/features/health_sync/presentation/controllers/biometric_controller.dart'; + +/// In-memory [IHealthSyncRepository] with the same `[from, to)` and +/// newest-first semantics as the drift implementation. +class _FakeHealthSyncRepository implements IHealthSyncRepository { + final List entries = []; + int _nextId = 1; + bool failNextSave = false; + + List _sorted(Iterable source) { + final list = source.toList() + ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); + return List.unmodifiable(list); + } + + static bool _inRange(DateTime timestamp, DateTime? from, DateTime? to) { + if (from != null && timestamp.isBefore(from)) return false; + if (to != null && !timestamp.isBefore(to)) return false; + return true; + } + + @override + Stream> watchBiometrics({int limit = 500}) => + Stream.value(_sorted(entries)); + + @override + Future> getBiometrics({ + int limit = 500, + DateTime? from, + DateTime? to, + }) async => + _sorted(entries.where((e) => _inRange(e.timestamp, from, to))); + + @override + Future saveBiometric(BiometricEntry entry) async { + if (failNextSave) { + failNextSave = false; + throw StateError('write failed'); + } + final stored = BiometricEntry( + id: entry.id ?? _nextId++, + timestamp: entry.timestamp, + weightLbs: entry.weightLbs, + activeCaloriesBurned: entry.activeCaloriesBurned, + source: entry.source, + ); + final index = entries.indexWhere((e) => e.id == stored.id); + if (index == -1) { + entries.add(stored); + } else { + entries[index] = stored; + } + return stored; + } + + @override + Future deleteBiometric(int id) async { + entries.removeWhere((entry) => entry.id == id); + } + + @override + Future getLatestBiometric() async => + entries.isEmpty ? null : _sorted(entries).first; + + @override + Future clearSyncedRange({ + required BiometricSource source, + required DateTime start, + required DateTime end, + }) async { + entries.removeWhere( + (entry) => + entry.source == source && + !entry.timestamp.isBefore(start) && + entry.timestamp.isBefore(end), + ); + } +} + +/// Scriptable [IHealthGateway] standing in for HealthKit / Health Connect. +class _FakeHealthGateway implements IHealthGateway { + bool nativeSupported = true; + bool granted = true; + List weights = []; + List energies = []; + int permissionRequests = 0; + int weightFetches = 0; + + @override + bool get supportsNativeHealth => nativeSupported; + + @override + NativeHealthPlatform? get nativePlatform => + nativeSupported ? NativeHealthPlatform.healthConnect : null; + + @override + Future requestPermissions() async { + permissionRequests++; + return granted; + } + + @override + Future> fetchWeightHistory({ + required DateTime start, + required DateTime end, + }) async { + weightFetches++; + return weights; + } + + @override + Future> fetchActiveEnergyBurned({ + required DateTime start, + required DateTime end, + }) async => + energies; + + @override + Future writeWeight({ + required double weightLbs, + required DateTime timestamp, + }) async => + nativeSupported; +} + +void main() { + late _FakeHealthSyncRepository repository; + late _FakeHealthGateway gateway; + late ProviderContainer container; + + setUp(() { + repository = _FakeHealthSyncRepository(); + gateway = _FakeHealthGateway(); + container = ProviderContainer( + overrides: [ + healthSyncRepositoryProvider.overrideWithValue(repository), + healthGatewayProvider.overrideWithValue(gateway), + ], + ); + addTearDown(container.dispose); + }); + + Future load() => + container.read(biometricControllerProvider.future); + + BiometricController notifier() => + container.read(biometricControllerProvider.notifier); + + BiometricEntry seed({ + required DateTime timestamp, + required double weightLbs, + double? activeCaloriesBurned, + BiometricSource source = BiometricSource.manual, + }) { + final entry = BiometricEntry( + id: repository.entries.length + 1, + timestamp: timestamp, + weightLbs: weightLbs, + activeCaloriesBurned: activeCaloriesBurned, + source: source, + ); + repository.entries.add(entry); + return entry; + } + + group('BiometricController manual entry', () { + test('logWeight persists a manual sample and exposes it as latest', + () async { + await load(); + final ok = await notifier().logWeight(weightLbs: 182.5); + + expect(ok, isTrue); + final state = container.read(biometricControllerProvider).valueOrNull!; + expect(state.latestWeightLbs, 182.5); + expect(state.latest!.source, BiometricSource.manual); + expect(state.statusMessage, contains('182.5 lb')); + expect(repository.entries, hasLength(1)); + }); + + test('logWeight rejects non-positive values', () async { + await load(); + expect(await notifier().logWeight(weightLbs: 0), isFalse); + expect(await notifier().logWeight(weightLbs: -3), isFalse); + expect(repository.entries, isEmpty); + }); + + test('deleteEntry removes a sample', () async { + await load(); + await notifier().logWeight(weightLbs: 200); + final id = container + .read(biometricControllerProvider) + .valueOrNull! + .latest! + .id!; + + await notifier().deleteEntry(id); + + final state = container.read(biometricControllerProvider).valueOrNull!; + expect(state.history, isEmpty); + expect(state.latestWeightLbs, isNull); + }); + + test('failed writes keep prior history and surface the error', () async { + await load(); + await notifier().logWeight(weightLbs: 175); + + repository.failNextSave = true; + final ok = await notifier().logWeight(weightLbs: 176); + + expect(ok, isFalse); + final state = container.read(biometricControllerProvider).valueOrNull!; + expect(state.latestWeightLbs, 175); + expect(state.error, isNotNull); + }); + }); + + group('BiometricController trends', () { + test('computes 7-day and 30-day deltas against the nearest baseline', + () async { + final now = DateTime.now(); + seed(timestamp: now, weightLbs: 200); + seed(timestamp: now.subtract(const Duration(days: 8)), weightLbs: 205); + seed(timestamp: now.subtract(const Duration(days: 31)), weightLbs: 210); + + final state = await load(); + + expect(state.latestWeightLbs, 200); + expect(state.weight7DayDelta, closeTo(-5, 1e-9)); + expect(state.weight30DayDelta, closeTo(-10, 1e-9)); + }); + + test('returns null deltas when history does not reach back', () async { + seed(timestamp: DateTime.now(), weightLbs: 190); + + final state = await load(); + + expect(state.latestWeightLbs, 190); + expect(state.weight7DayDelta, isNull); + expect(state.weight30DayDelta, isNull); + }); + + test('latestActiveCaloriesBurned surfaces the newest energy sample', + () async { + seed( + timestamp: DateTime.now(), + weightLbs: 180, + activeCaloriesBurned: 420, + ); + + final state = await load(); + expect(state.latestActiveCaloriesBurned, 420); + }); + }); + + group('BiometricController native sync', () { + test('fallback platform short-circuits without touching the gateway', + () async { + gateway.nativeSupported = false; + await load(); + + final ok = await notifier().syncFromHealth(); + + expect(ok, isFalse); + expect(gateway.permissionRequests, 0); + expect(gateway.weightFetches, 0); + final state = container.read(biometricControllerProvider).valueOrNull!; + expect(state.statusMessage, contains('unavailable')); + expect(repository.entries, isEmpty); + }); + + test('denied permission reports failure and stores nothing', () async { + gateway.granted = false; + await load(); + + final ok = await notifier().syncFromHealth(); + + expect(ok, isFalse); + expect(gateway.permissionRequests, 1); + final state = container.read(biometricControllerProvider).valueOrNull!; + expect(state.statusMessage, contains('denied')); + expect(repository.entries, isEmpty); + }); + + test( + 'imports weights and per-day active energy, then re-syncs without ' + 'duplicates', () async { + final now = DateTime.now(); + final eightDaysAgo = now.subtract(const Duration(days: 8)); + gateway.weights = [ + HealthMetric( + timestamp: now.subtract(const Duration(hours: 2)), + value: 180, + unit: 'lb', + ), + HealthMetric(timestamp: eightDaysAgo, value: 185, unit: 'lb'), + ]; + gateway.energies = [ + HealthMetric( + timestamp: now.subtract(const Duration(hours: 3)), + value: 100, + unit: 'kcal', + ), + HealthMetric( + timestamp: now.subtract(const Duration(hours: 1)), + value: 150, + unit: 'kcal', + ), + HealthMetric( + timestamp: eightDaysAgo.add(const Duration(hours: 1)), + value: 400, + unit: 'kcal', + ), + ]; + await load(); + + final ok = await notifier().syncFromHealth(); + + expect(ok, isTrue); + final state = container.read(biometricControllerProvider).valueOrNull!; + expect(state.statusMessage, contains('Synced 2 weight sample(s)')); + expect(state.statusMessage, contains('2 day(s) of active energy')); + expect(state.history, hasLength(2)); + expect( + state.history.every((e) => e.source == BiometricSource.healthConnect), + isTrue, + ); + // Today's sample carries the 100 + 150 kcal day total. + expect(state.history.first.activeCaloriesBurned, 250); + // The 8-day-old sample (newest-first ordering => last) carries its + // 400 kcal day total. + expect(state.history.last.activeCaloriesBurned, 400); + expect(state.history.last.weightLbs, 185); + + // Re-sync replaces the previous import instead of stacking rows. + await notifier().syncFromHealth(); + final afterResync = + container.read(biometricControllerProvider).valueOrNull!; + expect(afterResync.history, hasLength(2)); + }); + }); +} \ No newline at end of file diff --git a/test/features/nutrition/data/nutrition_repository_test.dart b/test/features/nutrition/data/nutrition_repository_test.dart new file mode 100644 index 0000000..b2df8c7 --- /dev/null +++ b/test/features/nutrition/data/nutrition_repository_test.dart @@ -0,0 +1,162 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:glp1_companion/core/database/app_database.dart'; +import 'package:glp1_companion/features/nutrition/data/repositories/nutrition_repository.dart'; +import 'package:glp1_companion/features/nutrition/domain/entities/food_log_entry.dart'; +import 'package:glp1_companion/features/nutrition/domain/entities/hydration_log_entry.dart'; +import 'package:glp1_companion/features/nutrition/domain/entities/meal_type.dart'; + +void main() { + late AppDatabase database; + late NutritionRepository repository; + + final morning = DateTime(2026, 10, 16, 8); + final noon = DateTime(2026, 10, 16, 12); + final previousDay = DateTime(2026, 10, 15, 9); + + setUp(() { + database = AppDatabase.forTesting(NativeDatabase.memory()); + repository = NutritionRepository(database); + }); + + tearDown(() => database.close()); + + FoodLogEntry makeFood({ + int? id, + DateTime? timestamp, + MealType mealType = MealType.breakfast, + String description = 'Greek yogurt', + double proteinGrams = 20, + int calories = 150, + double? carbsGrams, + double? fatGrams, + }) => + FoodLogEntry( + id: id, + timestamp: timestamp ?? morning, + mealType: mealType, + description: description, + proteinGrams: proteinGrams, + calories: calories, + carbsGrams: carbsGrams, + fatGrams: fatGrams, + ); + + group('NutritionRepository food diary', () { + test('saveFoodLog assigns an id and persists all fields', () async { + final saved = await repository.saveFoodLog( + makeFood( + mealType: MealType.lunch, + description: 'Chicken breast bowl', + proteinGrams: 42.5, + calories: 520, + carbsGrams: 45, + fatGrams: 12, + ), + ); + + expect(saved.id, isNotNull); + + final fetched = await repository.getFoodLogs(); + expect(fetched, hasLength(1)); + expect(fetched.single.id, saved.id); + expect(fetched.single.mealType, MealType.lunch); + expect(fetched.single.description, 'Chicken breast bowl'); + expect(fetched.single.proteinGrams, 42.5); + expect(fetched.single.calories, 520); + expect(fetched.single.carbsGrams, 45); + expect(fetched.single.fatGrams, 12); + }); + + test('getFoodLogs returns entries newest first', () async { + await repository.saveFoodLog(makeFood(timestamp: previousDay)); + await repository.saveFoodLog(makeFood(timestamp: noon)); + + final fetched = await repository.getFoodLogs(); + expect(fetched, hasLength(2)); + expect(fetched.first.timestamp, noon); + expect(fetched.last.timestamp, previousDay); + }); + + test('getFoodLogs filters by the half-open [from, to) range', () async { + await repository.saveFoodLog(makeFood(timestamp: previousDay)); + await repository.saveFoodLog(makeFood(timestamp: morning)); + await repository.saveFoodLog(makeFood(timestamp: noon)); + + final dayStart = DateTime(2026, 10, 16); + final dayEnd = DateTime(2026, 10, 17); + final todays = await repository.getFoodLogs(from: dayStart, to: dayEnd); + + expect(todays, hasLength(2)); + expect( + todays.every((entry) => entry.timestamp.isAfter(previousDay)), + isTrue, + ); + }); + + test('deleteFoodLog removes only the targeted row', () async { + final first = await repository.saveFoodLog(makeFood()); + await repository.saveFoodLog( + makeFood(mealType: MealType.dinner, description: 'Salmon'), + ); + + await repository.deleteFoodLog(first.id!); + + final remaining = await repository.getFoodLogs(); + expect(remaining, hasLength(1)); + expect(remaining.single.description, 'Salmon'); + }); + + test('watchFoodLogs emits on every write', () async { + final events = >[]; + final subscription = repository.watchFoodLogs().listen(events.add); + + await repository.saveFoodLog(makeFood()); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(events, isNotEmpty); + expect(events.last, hasLength(1)); + await subscription.cancel(); + }); + }); + + group('NutritionRepository hydration', () { + test('hydration entries persist and filter by day', () async { + await repository.saveHydrationLog( + HydrationLogEntry(timestamp: morning, volumeOz: 8), + ); + await repository.saveHydrationLog( + HydrationLogEntry(timestamp: noon, volumeOz: 16), + ); + await repository.saveHydrationLog( + HydrationLogEntry(timestamp: previousDay, volumeOz: 24), + ); + + final dayStart = DateTime(2026, 10, 16); + final dayEnd = DateTime(2026, 10, 17); + final todays = + await repository.getHydrationLogs(from: dayStart, to: dayEnd); + + expect(todays, hasLength(2)); + final totalOz = todays.fold(0, (sum, e) => sum + e.volumeOz); + expect(totalOz, 24); + + await repository.deleteHydrationLog(todays.first.id!); + final afterDelete = await repository.getHydrationLogs( + from: dayStart, + to: dayEnd, + ); + expect(afterDelete, hasLength(1)); + }); + }); + + group('MealType persistence keys', () { + test('tryParse round-trips every value', () { + for (final type in MealType.values) { + expect(MealType.tryParse(type.persistenceKey), type); + } + expect(MealType.tryParse('brunch'), isNull); + expect(MealType.tryParse(null), isNull); + }); + }); +} \ No newline at end of file diff --git a/test/features/nutrition/presentation/nutrition_controller_test.dart b/test/features/nutrition/presentation/nutrition_controller_test.dart new file mode 100644 index 0000000..975db7a --- /dev/null +++ b/test/features/nutrition/presentation/nutrition_controller_test.dart @@ -0,0 +1,291 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:glp1_companion/features/nutrition/domain/entities/food_log_entry.dart'; +import 'package:glp1_companion/features/nutrition/domain/entities/hydration_log_entry.dart'; +import 'package:glp1_companion/features/nutrition/domain/entities/meal_type.dart'; +import 'package:glp1_companion/features/nutrition/domain/repositories/nutrition_repository.dart'; +import 'package:glp1_companion/features/nutrition/presentation/controllers/nutrition_controller.dart'; + +/// In-memory [INutritionRepository] honoring the `[from, to)` range filter. +class _FakeNutritionRepository implements INutritionRepository { + final List foods = []; + final List hydration = []; + int _nextId = 1; + bool failNextSave = false; + + static bool _inRange(DateTime timestamp, DateTime? from, DateTime? to) { + if (from != null && timestamp.isBefore(from)) return false; + if (to != null && !timestamp.isBefore(to)) return false; + return true; + } + + List _sortedFood(Iterable entries) { + final list = entries.toList() + ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); + return List.unmodifiable(list); + } + + List _sortedWater(Iterable entries) { + final list = entries.toList() + ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); + return List.unmodifiable(list); + } + + void _maybeFail() { + if (failNextSave) { + failNextSave = false; + throw StateError('write failed'); + } + } + + @override + Stream> watchFoodLogs({int limit = 500}) => + Stream.value(_sortedFood(foods)); + + @override + Future> getFoodLogs({ + int limit = 500, + DateTime? from, + DateTime? to, + }) async => + _sortedFood(foods.where((e) => _inRange(e.timestamp, from, to))); + + @override + Future saveFoodLog(FoodLogEntry entry) async { + _maybeFail(); + final stored = FoodLogEntry( + id: entry.id ?? _nextId++, + timestamp: entry.timestamp, + mealType: entry.mealType, + description: entry.description, + proteinGrams: entry.proteinGrams, + calories: entry.calories, + carbsGrams: entry.carbsGrams, + fatGrams: entry.fatGrams, + ); + final index = foods.indexWhere((e) => e.id == stored.id); + if (index == -1) { + foods.add(stored); + } else { + foods[index] = stored; + } + return stored; + } + + @override + Future deleteFoodLog(int id) async { + foods.removeWhere((entry) => entry.id == id); + } + + @override + Stream> watchHydrationLogs({int limit = 500}) => + Stream.value(_sortedWater(hydration)); + + @override + Future> getHydrationLogs({ + int limit = 500, + DateTime? from, + DateTime? to, + }) async => + _sortedWater(hydration.where((e) => _inRange(e.timestamp, from, to))); + + @override + Future saveHydrationLog(HydrationLogEntry entry) async { + _maybeFail(); + final stored = HydrationLogEntry( + id: entry.id ?? _nextId++, + timestamp: entry.timestamp, + volumeOz: entry.volumeOz, + ); + hydration.add(stored); + return stored; + } + + @override + Future deleteHydrationLog(int id) async { + hydration.removeWhere((entry) => entry.id == id); + } +} + +void main() { + late _FakeNutritionRepository repository; + late ProviderContainer container; + + final today = DateTime.now(); + + setUp(() { + repository = _FakeNutritionRepository(); + container = ProviderContainer( + overrides: [ + nutritionRepositoryProvider.overrideWithValue(repository), + ], + ); + addTearDown(container.dispose); + }); + + Future load() => + container.read(nutritionControllerProvider.future); + + group('NutritionController daily targets', () { + test('build loads today-only entries and applies 100g/96oz targets', + () async { + repository.foods.add( + FoodLogEntry( + id: 1, + timestamp: DateTime(today.year, today.month, today.day, 8), + mealType: MealType.breakfast, + description: 'Eggs', + proteinGrams: 30, + calories: 320, + ), + ); + repository.hydration.add( + HydrationLogEntry( + id: 2, + timestamp: DateTime(today.year, today.month, today.day, 9), + volumeOz: 16, + ), + ); + // Outside today's window: must be excluded. + repository.foods.add( + FoodLogEntry( + id: 3, + timestamp: DateTime(today.year, today.month, today.day - 1, 8), + mealType: MealType.dinner, + description: 'Yesterday stew', + proteinGrams: 50, + calories: 600, + ), + ); + + final state = await load(); + + expect(state.foodLogs, hasLength(1)); + expect(state.totalProteinGrams, 30); + expect(state.proteinTargetGrams, 100); + expect(state.proteinProgress, 0.3); + expect(state.totalWaterOz, 16); + expect(state.hydrationTargetOz, 96); + expect(state.hydrationProgress, closeTo(16 / 96, 1e-9)); + }); + + test('logFood updates totals and groups meals by type', () async { + final initial = await load(); + expect(initial.foodLogs, isEmpty); + expect(initial.mealsByType, isEmpty); + + final notifier = container.read(nutritionControllerProvider.notifier); + final okBreakfast = await notifier.logFood( + mealType: MealType.breakfast, + description: 'Protein shake', + proteinGrams: 40, + calories: 220, + ); + await notifier.logFood( + mealType: MealType.lunch, + description: 'Chicken salad', + proteinGrams: 45.5, + calories: 480, + carbsGrams: 20, + fatGrams: 15, + ); + + expect(okBreakfast, isTrue); + + final state = container.read(nutritionControllerProvider).valueOrNull!; + expect(state.foodLogs, hasLength(2)); + expect(state.totalProteinGrams, closeTo(85.5, 1e-9)); + expect(state.totalCalories, 700); + expect(state.totalCarbsGrams, 20); + expect(state.totalFatGrams, 15); + expect(state.mealsByType.keys, [MealType.breakfast, MealType.lunch]); + expect(state.proteinProgress, closeTo(0.855, 1e-9)); + }); + + test('logFood rejects empty descriptions and negative macros', () async { + await load(); + final notifier = container.read(nutritionControllerProvider.notifier); + + expect( + await notifier.logFood( + mealType: MealType.snack, + description: ' ', + proteinGrams: 10, + calories: 100, + ), + isFalse, + ); + expect( + await notifier.logFood( + mealType: MealType.snack, + description: 'Bad', + proteinGrams: -1, + calories: 100, + ), + isFalse, + ); + expect( + container.read(nutritionControllerProvider).valueOrNull!.foodLogs, + isEmpty, + ); + }); + + test('quick-add water accumulates toward the 96 oz target', () async { + await load(); + final notifier = container.read(nutritionControllerProvider.notifier); + + await notifier.logWater(8); + await notifier.logWater(16); + await notifier.logWater(24); + + final state = container.read(nutritionControllerProvider).valueOrNull!; + expect(state.totalWaterOz, 48); + expect(state.hydrationLogs, hasLength(3)); + expect(state.hydrationProgress, 0.5); + + expect(await notifier.logWater(0), isFalse); + }); + + test('deleteFood and deleteWater remove entries and refresh totals', + () async { + await load(); + final notifier = container.read(nutritionControllerProvider.notifier); + + await notifier.logFood( + mealType: MealType.dinner, + description: 'Tofu stir fry', + proteinGrams: 25, + calories: 300, + ); + await notifier.logWater(16); + + var state = container.read(nutritionControllerProvider).valueOrNull!; + final foodId = state.foodLogs.single.id!; + final waterId = state.hydrationLogs.single.id!; + + await notifier.deleteFood(foodId); + await notifier.deleteWater(waterId); + + state = container.read(nutritionControllerProvider).valueOrNull!; + expect(state.foodLogs, isEmpty); + expect(state.hydrationLogs, isEmpty); + expect(state.totalProteinGrams, 0); + expect(state.totalWaterOz, 0); + }); + + test('failed writes keep the previous state and report false', () async { + await load(); + final notifier = container.read(nutritionControllerProvider.notifier); + await notifier.logWater(8); + + repository.failNextSave = true; + final ok = await notifier.logWater(16); + + expect(ok, isFalse); + final state = container.read(nutritionControllerProvider).valueOrNull!; + // First entry survives; the rejected write was not applied. + expect(state.hydrationLogs, hasLength(1)); + expect(state.totalWaterOz, 8); + }); + }); +} \ No newline at end of file