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:
Mike Kell 2026-10-16 07:02:28 -04:00
parent 7682d8317a
commit 094cf08f25
23 changed files with 5142 additions and 91 deletions

View File

@ -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)

View File

@ -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

View File

@ -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 [];
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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}) {

View File

@ -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),
);
}

View File

@ -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,
);
}

View File

@ -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,
});
}

View File

@ -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,
);

View File

@ -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();
}
}

View File

@ -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),
);
}

View File

@ -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,
);
}

View File

@ -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,
);
}

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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,
);

View File

@ -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'),
),
],
),
),
);
} }
} }

View File

@ -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();
}
}

View File

@ -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));
});
});
}

View File

@ -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);
});
});
}

View File

@ -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);
});
});
}