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.
This commit is contained in:
parent
7682d8317a
commit
094cf08f25
|
|
@ -1,13 +1,13 @@
|
||||||
# Build Execution Ledger
|
# Build Execution Ledger
|
||||||
|
|
||||||
## Active Target
|
## Active Target
|
||||||
- **Current Milestone:** Phase 3: Nutrition, Protein Target Tracking & HealthGateway Sync
|
- **Current Milestone:** Phase 4: Recipe Web Scraper Engine & Meal Planning Calendar
|
||||||
- **Status:** Phase 2 Complete
|
- **Status:** Phase 3 Complete
|
||||||
|
|
||||||
## Phase Checklist
|
## Phase Checklist
|
||||||
- [x] Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB)
|
- [x] Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB)
|
||||||
- [x] Phase 2: Medication Domain, Injection Rotation, Serum Modeling
|
- [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 4: Recipe Web Scraper Engine & Meal Planning Calendar
|
||||||
- [ ] Phase 5: Pantry Delta Engine & Auto-Shopping Generator
|
- [ ] Phase 5: Pantry Delta Engine & Auto-Shopping Generator
|
||||||
- [ ] Phase 6: Hardening, Native Desktop/Web Polish, CI/CD
|
- [ ] Phase 6: Hardening, Native Desktop/Web Polish, CI/CD
|
||||||
|
|
@ -24,7 +24,17 @@
|
||||||
## Post-Phase-2 Additions
|
## 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).
|
- [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
|
## Active Verification Gates
|
||||||
- [x] `flutter pub run build_runner build --delete-conflicting-outputs`
|
- [x] `flutter pub run build_runner build --delete-conflicting-outputs`
|
||||||
- [x] `flutter analyze` (Zero issues)
|
- [x] `flutter analyze` (Zero issues — `No issues found!`)
|
||||||
- [x] `flutter test` (37/37 passing)
|
- [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)
|
||||||
|
|
@ -35,6 +35,71 @@ class MedicationDosesTable extends Table {
|
||||||
TextColumn get notes => text().nullable()();
|
TextColumn get notes => text().nullable()();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Immutable log of meal/snack intake for the nutrition diary.
|
||||||
|
///
|
||||||
|
/// [mealType] stores the meal slot as a stable string (the `MealType.name`
|
||||||
|
/// identifier of the nutrition domain enum) so the persistence schema stays
|
||||||
|
/// decoupled from domain types and the `core` layer never imports feature
|
||||||
|
/// code.
|
||||||
|
@DataClassName('FoodLogRecord')
|
||||||
|
class FoodLogsTable extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
|
||||||
|
/// When the food was consumed.
|
||||||
|
DateTimeColumn get timestamp => dateTime()();
|
||||||
|
|
||||||
|
/// Meal slot identifier (e.g. `breakfast`).
|
||||||
|
TextColumn get mealType => text().withLength(min: 1, max: 32)();
|
||||||
|
|
||||||
|
/// Free-text description of the meal.
|
||||||
|
TextColumn get description => text().withLength(min: 1, max: 512)();
|
||||||
|
|
||||||
|
/// Protein content in grams (lean-mass tracking metric).
|
||||||
|
RealColumn get proteinGrams => real()();
|
||||||
|
|
||||||
|
/// Energy content in kilocalories.
|
||||||
|
IntColumn get calories => integer()();
|
||||||
|
|
||||||
|
/// Carbohydrate content in grams; optional.
|
||||||
|
RealColumn get carbsGrams => real().nullable()();
|
||||||
|
|
||||||
|
/// Fat content in grams; optional.
|
||||||
|
RealColumn get fatGrams => real().nullable()();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immutable log of fluid intake (US fluid ounces).
|
||||||
|
@DataClassName('HydrationLogRecord')
|
||||||
|
class HydrationLogsTable extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
|
||||||
|
/// When the fluid was consumed.
|
||||||
|
DateTimeColumn get timestamp => dateTime()();
|
||||||
|
|
||||||
|
/// Consumed volume in fluid ounces.
|
||||||
|
RealColumn get volumeOz => real()();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immutable biometric sample: weight (lbs) plus optional active energy burn.
|
||||||
|
///
|
||||||
|
/// [source] stores the origin as a stable string (the `BiometricSource.name`
|
||||||
|
/// identifier of the health-sync domain enum).
|
||||||
|
@DataClassName('BiometricLogRecord')
|
||||||
|
class BiometricLogsTable extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
|
||||||
|
/// When the sample was recorded.
|
||||||
|
DateTimeColumn get timestamp => dateTime()();
|
||||||
|
|
||||||
|
/// Body weight in pounds.
|
||||||
|
RealColumn get weightLbs => real()();
|
||||||
|
|
||||||
|
/// Active energy burned (kcal); `null` for weight-only samples.
|
||||||
|
RealColumn get activeCaloriesBurned => real().nullable()();
|
||||||
|
|
||||||
|
/// Origin identifier (e.g. `appleHealth`, `manual`).
|
||||||
|
TextColumn get source => text().withLength(min: 1, max: 32)();
|
||||||
|
}
|
||||||
|
|
||||||
/// Root drift database for the GLP-1 Companion.
|
/// Root drift database for the GLP-1 Companion.
|
||||||
///
|
///
|
||||||
/// On native platforms the connection is backed by SQLCipher: the 256-bit AES
|
/// 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
|
/// immediately after the database opens (see `connection/native.dart`). Web
|
||||||
/// builds transparently fall back to a WASM SQLite database, since SQLCipher
|
/// builds transparently fall back to a WASM SQLite database, since SQLCipher
|
||||||
/// cannot run inside the browser.
|
/// cannot run inside the browser.
|
||||||
@DriftDatabase(tables: [MedicationDosesTable])
|
@DriftDatabase(
|
||||||
|
tables: [MedicationDosesTable, FoodLogsTable, HydrationLogsTable, BiometricLogsTable],
|
||||||
|
)
|
||||||
class AppDatabase extends _$AppDatabase {
|
class AppDatabase extends _$AppDatabase {
|
||||||
/// Opens the platform-appropriate (encrypted on native) database connection.
|
/// Opens the platform-appropriate (encrypted on native) database connection.
|
||||||
AppDatabase() : super(openDatabaseConnection());
|
AppDatabase() : super(openDatabaseConnection());
|
||||||
|
|
@ -51,7 +118,21 @@ class AppDatabase extends _$AppDatabase {
|
||||||
AppDatabase.forTesting(super.executor);
|
AppDatabase.forTesting(super.executor);
|
||||||
|
|
||||||
@override
|
@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
|
// Injection history CRUD
|
||||||
|
|
@ -116,6 +197,212 @@ class AppDatabase extends _$AppDatabase {
|
||||||
(t) => OrderingTerm.desc(t.administeredTime),
|
(t) => OrderingTerm.desc(t.administeredTime),
|
||||||
(t) => OrderingTerm.desc(t.id),
|
(t) => OrderingTerm.desc(t.id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Food diary CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Same contract as the dose helpers: persistence records only — mapping to
|
||||||
|
// the nutrition feature's `FoodLogEntry` happens in
|
||||||
|
// `features/nutrition/data/repositories/nutrition_repository.dart`.
|
||||||
|
|
||||||
|
/// Emits the food diary, newest first, and re-emits on every write.
|
||||||
|
///
|
||||||
|
/// When [from]/[to] are provided only rows in the half-open interval
|
||||||
|
/// `[from, to)` are emitted (used for the "today" view).
|
||||||
|
Stream<List<FoodLogRecord>> watchFoodLogs({
|
||||||
|
int limit = 500,
|
||||||
|
DateTime? from,
|
||||||
|
DateTime? to,
|
||||||
|
}) =>
|
||||||
|
(_selectFoodLogs(from: from, to: to)..limit(limit)).watch();
|
||||||
|
|
||||||
|
/// One-shot snapshot of the food diary, newest first.
|
||||||
|
Future<List<FoodLogRecord>> getFoodLogs({
|
||||||
|
int limit = 500,
|
||||||
|
DateTime? from,
|
||||||
|
DateTime? to,
|
||||||
|
}) =>
|
||||||
|
(_selectFoodLogs(from: from, to: to)..limit(limit)).get();
|
||||||
|
|
||||||
|
/// Inserts a new row and returns the persisted record including its
|
||||||
|
/// auto-incremented primary key.
|
||||||
|
Future<FoodLogRecord> insertFoodLog(FoodLogsTableCompanion entry) async {
|
||||||
|
final id = await into(foodLogsTable).insert(entry);
|
||||||
|
return (select(foodLogsTable)..where((t) => t.id.equals(id))).getSingle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Partially updates the row identified by [id] (only non-absent companion
|
||||||
|
/// columns are written).
|
||||||
|
Future<void> updateFoodLog(int id, FoodLogsTableCompanion updates) =>
|
||||||
|
(update(foodLogsTable)..where((t) => t.id.equals(id))).write(updates);
|
||||||
|
|
||||||
|
/// Deletes the food row identified by [id]; returns the number of removed
|
||||||
|
/// rows.
|
||||||
|
Future<int> deleteFoodLog(int id) =>
|
||||||
|
(delete(foodLogsTable)..where((t) => t.id.equals(id))).go();
|
||||||
|
|
||||||
|
/// Newest-first food query shared by [watchFoodLogs] and [getFoodLogs].
|
||||||
|
SimpleSelectStatement<$FoodLogsTableTable, FoodLogRecord> _selectFoodLogs({
|
||||||
|
DateTime? from,
|
||||||
|
DateTime? to,
|
||||||
|
}) {
|
||||||
|
final query = select(foodLogsTable);
|
||||||
|
if (from != null) {
|
||||||
|
query.where((t) => t.timestamp.isBiggerOrEqualValue(from));
|
||||||
|
}
|
||||||
|
if (to != null) {
|
||||||
|
query.where((t) => t.timestamp.isSmallerThanValue(to));
|
||||||
|
}
|
||||||
|
return query
|
||||||
|
..orderBy([
|
||||||
|
(t) => OrderingTerm.desc(t.timestamp),
|
||||||
|
(t) => OrderingTerm.desc(t.id),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hydration CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Emits the hydration log, newest first, and re-emits on every write.
|
||||||
|
Stream<List<HydrationLogRecord>> watchHydrationLogs({
|
||||||
|
int limit = 500,
|
||||||
|
DateTime? from,
|
||||||
|
DateTime? to,
|
||||||
|
}) =>
|
||||||
|
(_selectHydrationLogs(from: from, to: to)..limit(limit)).watch();
|
||||||
|
|
||||||
|
/// One-shot snapshot of the hydration log, newest first.
|
||||||
|
Future<List<HydrationLogRecord>> getHydrationLogs({
|
||||||
|
int limit = 500,
|
||||||
|
DateTime? from,
|
||||||
|
DateTime? to,
|
||||||
|
}) =>
|
||||||
|
(_selectHydrationLogs(from: from, to: to)..limit(limit)).get();
|
||||||
|
|
||||||
|
/// Inserts a new row and returns the persisted record including its
|
||||||
|
/// auto-incremented primary key.
|
||||||
|
Future<HydrationLogRecord> insertHydrationLog(
|
||||||
|
HydrationLogsTableCompanion entry,
|
||||||
|
) async {
|
||||||
|
final id = await into(hydrationLogsTable).insert(entry);
|
||||||
|
return (select(hydrationLogsTable)..where((t) => t.id.equals(id)))
|
||||||
|
.getSingle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Partially updates the row identified by [id].
|
||||||
|
Future<void> updateHydrationLog(
|
||||||
|
int id,
|
||||||
|
HydrationLogsTableCompanion updates,
|
||||||
|
) =>
|
||||||
|
(update(hydrationLogsTable)..where((t) => t.id.equals(id))).write(updates);
|
||||||
|
|
||||||
|
/// Deletes the hydration row identified by [id].
|
||||||
|
Future<int> deleteHydrationLog(int id) =>
|
||||||
|
(delete(hydrationLogsTable)..where((t) => t.id.equals(id))).go();
|
||||||
|
|
||||||
|
/// Newest-first hydration query shared by the watch/get helpers.
|
||||||
|
SimpleSelectStatement<$HydrationLogsTableTable, HydrationLogRecord>
|
||||||
|
_selectHydrationLogs({DateTime? from, DateTime? to}) {
|
||||||
|
final query = select(hydrationLogsTable);
|
||||||
|
if (from != null) {
|
||||||
|
query.where((t) => t.timestamp.isBiggerOrEqualValue(from));
|
||||||
|
}
|
||||||
|
if (to != null) {
|
||||||
|
query.where((t) => t.timestamp.isSmallerThanValue(to));
|
||||||
|
}
|
||||||
|
return query
|
||||||
|
..orderBy([
|
||||||
|
(t) => OrderingTerm.desc(t.timestamp),
|
||||||
|
(t) => OrderingTerm.desc(t.id),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Biometric CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Emits the biometric history, newest first, and re-emits on every write.
|
||||||
|
Stream<List<BiometricLogRecord>> watchBiometrics({
|
||||||
|
int limit = 500,
|
||||||
|
DateTime? from,
|
||||||
|
DateTime? to,
|
||||||
|
}) =>
|
||||||
|
(_selectBiometrics(from: from, to: to)..limit(limit)).watch();
|
||||||
|
|
||||||
|
/// One-shot snapshot of the biometric history, newest first.
|
||||||
|
Future<List<BiometricLogRecord>> getBiometrics({
|
||||||
|
int limit = 500,
|
||||||
|
DateTime? from,
|
||||||
|
DateTime? to,
|
||||||
|
}) =>
|
||||||
|
(_selectBiometrics(from: from, to: to)..limit(limit)).get();
|
||||||
|
|
||||||
|
/// Inserts a new row and returns the persisted record including its
|
||||||
|
/// auto-incremented primary key.
|
||||||
|
Future<BiometricLogRecord> insertBiometricLog(
|
||||||
|
BiometricLogsTableCompanion entry,
|
||||||
|
) async {
|
||||||
|
final id = await into(biometricLogsTable).insert(entry);
|
||||||
|
return (select(biometricLogsTable)..where((t) => t.id.equals(id)))
|
||||||
|
.getSingle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Partially updates the row identified by [id].
|
||||||
|
Future<void> updateBiometricLog(
|
||||||
|
int id,
|
||||||
|
BiometricLogsTableCompanion updates,
|
||||||
|
) =>
|
||||||
|
(update(biometricLogsTable)..where((t) => t.id.equals(id)))
|
||||||
|
.write(updates);
|
||||||
|
|
||||||
|
/// Deletes the biometric row identified by [id].
|
||||||
|
Future<int> deleteBiometricLog(int id) =>
|
||||||
|
(delete(biometricLogsTable)..where((t) => t.id.equals(id))).go();
|
||||||
|
|
||||||
|
/// Most recent biometric row, or `null` on an empty history.
|
||||||
|
Future<BiometricLogRecord?> latestBiometric() =>
|
||||||
|
(select(biometricLogsTable)
|
||||||
|
..orderBy([
|
||||||
|
(t) => OrderingTerm.desc(t.timestamp),
|
||||||
|
(t) => OrderingTerm.desc(t.id),
|
||||||
|
])
|
||||||
|
..limit(1))
|
||||||
|
.getSingleOrNull();
|
||||||
|
|
||||||
|
/// Deletes rows produced by [sourceName] within `[start, end)`; used to
|
||||||
|
/// make health re-syncs idempotent.
|
||||||
|
Future<int> deleteBiometricsFromSourceWithin({
|
||||||
|
required String sourceName,
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
}) =>
|
||||||
|
(delete(biometricLogsTable)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.source.equals(sourceName) &
|
||||||
|
t.timestamp.isBiggerOrEqualValue(start) &
|
||||||
|
t.timestamp.isSmallerThanValue(end),
|
||||||
|
))
|
||||||
|
.go();
|
||||||
|
|
||||||
|
/// Newest-first biometric query shared by the watch/get helpers.
|
||||||
|
SimpleSelectStatement<$BiometricLogsTableTable, BiometricLogRecord>
|
||||||
|
_selectBiometrics({DateTime? from, DateTime? to}) {
|
||||||
|
final query = select(biometricLogsTable);
|
||||||
|
if (from != null) {
|
||||||
|
query.where((t) => t.timestamp.isBiggerOrEqualValue(from));
|
||||||
|
}
|
||||||
|
if (to != null) {
|
||||||
|
query.where((t) => t.timestamp.isSmallerThanValue(to));
|
||||||
|
}
|
||||||
|
return query
|
||||||
|
..orderBy([
|
||||||
|
(t) => OrderingTerm.desc(t.timestamp),
|
||||||
|
(t) => OrderingTerm.desc(t.id),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Application-wide database instance; closed when the container is disposed.
|
/// Application-wide database instance; closed when the container is disposed.
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,58 +1,87 @@
|
||||||
import 'dart:io';
|
import 'health_gateway_fallback.dart'
|
||||||
import 'package:flutter/foundation.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 {
|
class HealthMetric {
|
||||||
final DateTime timestamp;
|
|
||||||
final double value;
|
|
||||||
final String unit;
|
|
||||||
|
|
||||||
const HealthMetric({
|
const HealthMetric({
|
||||||
required this.timestamp,
|
required this.timestamp,
|
||||||
required this.value,
|
required this.value,
|
||||||
required this.unit,
|
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 {
|
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<bool> requestPermissions();
|
Future<bool> 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<List<HealthMetric>> fetchWeightHistory({
|
Future<List<HealthMetric>> fetchWeightHistory({
|
||||||
required DateTime start,
|
required DateTime start,
|
||||||
required DateTime end,
|
required DateTime end,
|
||||||
});
|
});
|
||||||
Future<List<HealthMetric>> 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<List<HealthMetric>> fetchActiveEnergyBurned({
|
||||||
required DateTime start,
|
required DateTime start,
|
||||||
required DateTime end,
|
required DateTime end,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Writes a weight measurement back to the native store; returns whether
|
||||||
|
/// the platform accepted it (always `false` in fallback mode).
|
||||||
|
Future<bool> writeWeight({
|
||||||
|
required double weightLbs,
|
||||||
|
required DateTime timestamp,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class PlatformHealthGateway implements IHealthGateway {
|
/// Platform-appropriate factory: native `health`-backed gateway on
|
||||||
@override
|
/// Android/iOS, manual-entry fallback on Web/Windows/macOS.
|
||||||
Future<bool> requestPermissions() async {
|
IHealthGateway createHealthGateway() => impl.makeHealthGateway();
|
||||||
if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<HealthMetric>> fetchWeightHistory({
|
|
||||||
required DateTime start,
|
|
||||||
required DateTime end,
|
|
||||||
}) async {
|
|
||||||
if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<HealthMetric>> fetchWorkouts({
|
|
||||||
required DateTime start,
|
|
||||||
required DateTime end,
|
|
||||||
}) async {
|
|
||||||
if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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<bool> requestPermissions() async => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<HealthMetric>> fetchWeightHistory({
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
}) async =>
|
||||||
|
const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<HealthMetric>> fetchActiveEnergyBurned({
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
}) async =>
|
||||||
|
const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> writeWeight({
|
||||||
|
required double weightLbs,
|
||||||
|
required DateTime timestamp,
|
||||||
|
}) async =>
|
||||||
|
false;
|
||||||
|
}
|
||||||
|
|
@ -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<void>? _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<void> _configure() => _configuration ??= _health.configure();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> 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<List<HealthMetric>> fetchWeightHistory({
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
}) async =>
|
||||||
|
_fetch(
|
||||||
|
type: HealthDataType.WEIGHT,
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
normalize: _weightInLbs,
|
||||||
|
unit: 'lb',
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<HealthMetric>> fetchActiveEnergyBurned({
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
}) async =>
|
||||||
|
_fetch(
|
||||||
|
type: HealthDataType.ACTIVE_ENERGY_BURNED,
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
normalize: _energyInKcal,
|
||||||
|
unit: 'kcal',
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> 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<List<HealthMetric>> _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 = <HealthMetric>[];
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,17 @@ String formatDateTime(DateTime time) {
|
||||||
' · $hour12:$minute $period';
|
' · $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
|
/// Formats a coarse elapsed time relative to [now], e.g. `never`, `3 hours
|
||||||
/// ago`, `5 days ago`.
|
/// ago`, `5 days ago`.
|
||||||
String formatRelativeTime(DateTime time, {DateTime? now}) {
|
String formatRelativeTime(DateTime time, {DateTime? now}) {
|
||||||
|
|
|
||||||
|
|
@ -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<List<BiometricEntry>> watchBiometrics({int limit = 500}) => _database
|
||||||
|
.watchBiometrics(limit: limit)
|
||||||
|
.map((records) => records.map(_mapRecord).toList(growable: false));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<BiometricEntry>> 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<BiometricEntry> 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<void> deleteBiometric(int id) => _database.deleteBiometricLog(id);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BiometricEntry?> getLatestBiometric() async {
|
||||||
|
final record = await _database.latestBiometric();
|
||||||
|
return record == null ? null : _mapRecord(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<List<BiometricEntry>> watchBiometrics({int limit = 500});
|
||||||
|
|
||||||
|
/// One-shot snapshot of the biometric history (newest first), optionally
|
||||||
|
/// filtered by the half-open interval `[from, to)`.
|
||||||
|
Future<List<BiometricEntry>> 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<BiometricEntry> saveBiometric(BiometricEntry entry);
|
||||||
|
|
||||||
|
/// Removes the biometric entry with [id], if present.
|
||||||
|
Future<void> deleteBiometric(int id);
|
||||||
|
|
||||||
|
/// Most recent entry, or `null` on an empty history.
|
||||||
|
Future<BiometricEntry?> getLatestBiometric();
|
||||||
|
|
||||||
|
/// Deletes every entry produced by [source] within `[start, end)` so a
|
||||||
|
/// re-sync replaces its previous import instead of duplicating rows.
|
||||||
|
Future<void> clearSyncedRange({
|
||||||
|
required BiometricSource source,
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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<IHealthSyncRepository>(
|
||||||
|
(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<IHealthGateway>(
|
||||||
|
(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<BiometricEntry> 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<BiometricEntry>? 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<BiometricState> {
|
||||||
|
/// 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<BiometricState> build() => _load();
|
||||||
|
|
||||||
|
/// Persists a manually entered weight sample and refreshes trends.
|
||||||
|
Future<bool> 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<bool> 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<bool> 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 = <BiometricEntry>[];
|
||||||
|
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<void> 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<BiometricState> _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<void> _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<BiometricState> next) => state = next;
|
||||||
|
|
||||||
|
/// Sums active-energy samples per local calendar day (day-start anchors),
|
||||||
|
/// returned in chronological order.
|
||||||
|
Map<DateTime, double> _groupEnergyByDay(List<HealthMetric> energies) {
|
||||||
|
final perDay = <DateTime, double>{};
|
||||||
|
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<HealthMetric> energies,
|
||||||
|
required List<BiometricEntry> savedWeights,
|
||||||
|
required List<BiometricEntry> 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, BiometricState>(
|
||||||
|
BiometricController.new,
|
||||||
|
);
|
||||||
|
|
@ -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<bool?> showLogWeightDialog(BuildContext context) {
|
||||||
|
return showDialog<bool>(
|
||||||
|
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<LogWeightDialog> createState() => _LogWeightDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LogWeightDialogState extends ConsumerState<LogWeightDialog> {
|
||||||
|
late final TextEditingController _weightController = TextEditingController();
|
||||||
|
late DateTime _measuredAt = DateTime.now();
|
||||||
|
|
||||||
|
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||||
|
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<void> _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<void> _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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<List<FoodLogEntry>> watchFoodLogs({int limit = 500}) => _database
|
||||||
|
.watchFoodLogs(limit: limit)
|
||||||
|
.map((records) => records.map(_mapFood).toList(growable: false));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<FoodLogEntry>> 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<FoodLogEntry> 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<void> deleteFoodLog(int id) => _database.deleteFoodLog(id);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hydration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<HydrationLogEntry>> watchHydrationLogs({int limit = 500}) =>
|
||||||
|
_database.watchHydrationLogs(limit: limit).map(
|
||||||
|
(records) => records.map(_mapHydration).toList(growable: false),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<HydrationLogEntry>> 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<HydrationLogEntry> 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<void> 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<List<FoodLogEntry>> 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<List<FoodLogEntry>> 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<FoodLogEntry> saveFoodLog(FoodLogEntry entry);
|
||||||
|
|
||||||
|
/// Removes the food entry with [id], if present.
|
||||||
|
Future<void> deleteFoodLog(int id);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hydration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Emits the hydration log (newest first) whenever it changes.
|
||||||
|
Stream<List<HydrationLogEntry>> watchHydrationLogs({int limit = 500});
|
||||||
|
|
||||||
|
/// One-shot snapshot of the hydration log (newest first), optionally
|
||||||
|
/// filtered by the half-open interval `[from, to)`.
|
||||||
|
Future<List<HydrationLogEntry>> 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<HydrationLogEntry> saveHydrationLog(HydrationLogEntry entry);
|
||||||
|
|
||||||
|
/// Removes the hydration entry with [id], if present.
|
||||||
|
Future<void> deleteHydrationLog(int id);
|
||||||
|
}
|
||||||
|
|
@ -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<INutritionRepository>(
|
||||||
|
(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<FoodLogEntry> foodLogs;
|
||||||
|
|
||||||
|
/// Hydration entries logged on [day], newest first.
|
||||||
|
final List<HydrationLogEntry> 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<MealType, List<FoodLogEntry>> get mealsByType {
|
||||||
|
final grouped = <MealType, List<FoodLogEntry>>{};
|
||||||
|
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<NutritionState> {
|
||||||
|
/// 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<NutritionState> 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<bool> 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<bool> 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<bool> 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<bool> 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<void> 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<NutritionState> _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<void> _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, NutritionState>(
|
||||||
|
NutritionController.new,
|
||||||
|
);
|
||||||
|
|
@ -1,58 +1,649 @@
|
||||||
import 'package:flutter/material.dart';
|
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});
|
const NutritionScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final nutrition = ref.watch(nutritionControllerProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Nutrition & Hydration')),
|
appBar: AppBar(title: const Text('Nutrition & Hydration')),
|
||||||
body: ListView(
|
body: Center(
|
||||||
padding: const EdgeInsets.all(16.0),
|
child: ConstrainedBox(
|
||||||
children: const [
|
constraints: const BoxConstraints(maxWidth: 720),
|
||||||
Card(
|
child: nutrition.when(
|
||||||
child: Padding(
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
padding: EdgeInsets.all(16.0),
|
error: (error, _) => _ErrorView(
|
||||||
child: Column(
|
error: error,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
onRetry: () =>
|
||||||
children: [
|
ref.read(nutritionControllerProvider.notifier).retry(),
|
||||||
Text(
|
|
||||||
'Daily Protein Target',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
|
||||||
),
|
),
|
||||||
SizedBox(height: 8),
|
data: (state) => ListView(
|
||||||
LinearProgressIndicator(value: 0.65, minHeight: 12),
|
padding: const EdgeInsets.all(16),
|
||||||
SizedBox(height: 8),
|
children: [
|
||||||
Text('65g / 100g goal'),
|
_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(
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(16.0),
|
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 = <String>['${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(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Daily Hydration',
|
'Daily Hydration',
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.titleMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
SizedBox(height: 8),
|
const SizedBox(height: 12),
|
||||||
LinearProgressIndicator(
|
ClipRRect(
|
||||||
value: 0.5,
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: progress,
|
||||||
minHeight: 12,
|
minHeight: 12,
|
||||||
color: Colors.blue,
|
color: reachedGoal ? Colors.teal : Colors.blue,
|
||||||
|
backgroundColor:
|
||||||
|
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
),
|
),
|
||||||
SizedBox(height: 8),
|
),
|
||||||
Text('48 oz / 96 oz goal'),
|
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<void> _quickAdd(WidgetRef ref, double ounces) =>
|
||||||
|
ref.read(nutritionControllerProvider.notifier).logWater(ounces);
|
||||||
|
|
||||||
|
Future<void> _confirmDelete(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
HydrationLogEntry entry,
|
||||||
|
) async {
|
||||||
|
final id = entry.id;
|
||||||
|
if (id == null) return;
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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<void> _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<double>(
|
||||||
|
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<String>(
|
||||||
|
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 = <String>[
|
||||||
|
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<void> _confirmDelete(BuildContext context, WidgetRef ref) async {
|
||||||
|
final id = entry.id;
|
||||||
|
if (id == null) return;
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<bool?> showLogFoodDialog(
|
||||||
|
BuildContext context, {
|
||||||
|
MealType initialMealType = MealType.breakfast,
|
||||||
|
}) {
|
||||||
|
return showDialog<bool>(
|
||||||
|
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<LogFoodDialog> createState() => _LogFoodDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LogFoodDialogState extends ConsumerState<LogFoodDialog> {
|
||||||
|
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<FormState> _formKey = GlobalKey<FormState>();
|
||||||
|
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<MealType>(
|
||||||
|
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<void> _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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<BiometricEntry> entries = [];
|
||||||
|
int _nextId = 1;
|
||||||
|
bool failNextSave = false;
|
||||||
|
|
||||||
|
List<BiometricEntry> _sorted(Iterable<BiometricEntry> 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<List<BiometricEntry>> watchBiometrics({int limit = 500}) =>
|
||||||
|
Stream.value(_sorted(entries));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<BiometricEntry>> getBiometrics({
|
||||||
|
int limit = 500,
|
||||||
|
DateTime? from,
|
||||||
|
DateTime? to,
|
||||||
|
}) async =>
|
||||||
|
_sorted(entries.where((e) => _inRange(e.timestamp, from, to)));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BiometricEntry> 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<void> deleteBiometric(int id) async {
|
||||||
|
entries.removeWhere((entry) => entry.id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BiometricEntry?> getLatestBiometric() async =>
|
||||||
|
entries.isEmpty ? null : _sorted(entries).first;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> 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<HealthMetric> weights = [];
|
||||||
|
List<HealthMetric> energies = [];
|
||||||
|
int permissionRequests = 0;
|
||||||
|
int weightFetches = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get supportsNativeHealth => nativeSupported;
|
||||||
|
|
||||||
|
@override
|
||||||
|
NativeHealthPlatform? get nativePlatform =>
|
||||||
|
nativeSupported ? NativeHealthPlatform.healthConnect : null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> requestPermissions() async {
|
||||||
|
permissionRequests++;
|
||||||
|
return granted;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<HealthMetric>> fetchWeightHistory({
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
}) async {
|
||||||
|
weightFetches++;
|
||||||
|
return weights;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<HealthMetric>> fetchActiveEnergyBurned({
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
}) async =>
|
||||||
|
energies;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> 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<BiometricState> 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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 = <List<FoodLogEntry>>[];
|
||||||
|
final subscription = repository.watchFoodLogs().listen(events.add);
|
||||||
|
|
||||||
|
await repository.saveFoodLog(makeFood());
|
||||||
|
await Future<void>.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<double>(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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<FoodLogEntry> foods = [];
|
||||||
|
final List<HydrationLogEntry> 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<FoodLogEntry> _sortedFood(Iterable<FoodLogEntry> entries) {
|
||||||
|
final list = entries.toList()
|
||||||
|
..sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
||||||
|
return List.unmodifiable(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<HydrationLogEntry> _sortedWater(Iterable<HydrationLogEntry> 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<List<FoodLogEntry>> watchFoodLogs({int limit = 500}) =>
|
||||||
|
Stream.value(_sortedFood(foods));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<FoodLogEntry>> getFoodLogs({
|
||||||
|
int limit = 500,
|
||||||
|
DateTime? from,
|
||||||
|
DateTime? to,
|
||||||
|
}) async =>
|
||||||
|
_sortedFood(foods.where((e) => _inRange(e.timestamp, from, to)));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FoodLogEntry> 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<void> deleteFoodLog(int id) async {
|
||||||
|
foods.removeWhere((entry) => entry.id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<HydrationLogEntry>> watchHydrationLogs({int limit = 500}) =>
|
||||||
|
Stream.value(_sortedWater(hydration));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<HydrationLogEntry>> getHydrationLogs({
|
||||||
|
int limit = 500,
|
||||||
|
DateTime? from,
|
||||||
|
DateTime? to,
|
||||||
|
}) async =>
|
||||||
|
_sortedWater(hydration.where((e) => _inRange(e.timestamp, from, to)));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HydrationLogEntry> 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<void> 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<NutritionState> 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue