feat(platform,tests,ci): complete Phase 6 cross-platform hardening, integration tests, and Forgejo CI/CD

- Harden HealthGateway: kIsWeb/defaultTargetPlatform/dart:io guards, Health
  Connect SDK availability probe with graceful degradation, READ_WEIGHT/
  READ_STEPS/READ_TOTAL_CALORIES_BURNED permission validation before reads,
  HealthKit entitlement failures contained (false + debug warning), and a
  desktop/web fallback that serves locally logged biometrics through the new
  ILocalBiometricReader contract (drift-backed adapter wired in health_sync).
- Platform-safe CryptoManager vault: EncryptedSharedPreferences (Android),
  Keychain first_unlock (iOS/macOS), DPAPI-sealed file (Windows), libsecret
  (Linux); vault failures degrade to an ephemeral in-memory SQLCipher key.
- Declare the four Health Connect READ_* permissions in AndroidManifest.xml.
- Extract isAuthenticatedProvider so integration tests can simulate auth
  sessions without booting Supabase.
- Add 24 tests (99 -> 123): steady-state PK suite (Semaglutide 168h /
  Tirzepatide 120h geometric series, superposition, titration, washout),
  real-world recipe JSON-LD fixtures (BBC-style root, AllRecipes @graph),
  pantry unit-conversion group (kg/g, l/ml, dimension guard, synonyms),
  app navigation integration tests (rail + bar, authenticated + dev-bypass
  + unauthenticated), and reactive nutrition progress-bar widget tests.
- Fix NutritionScreen narrow-width overflows: Flexible protein title and
  Wrap action buttons stack cleanly on compact phones.
- Add .forgejo/workflows/ci.yml for git.kellsupport.com/mtkell/glp-companion
  (push to main + PRs, Flutter container: checkout, mock .env, pub get,
  build_runner, analyze --fatal-infos, test --coverage, build bundle + web).
- Remove dead lib/features/recipes/data/recipe_scraper.dart; rewrite
  README.md (architecture, dev setup, SQLCipher details, CI docs); add
  .env.example; allow it through .gitignore; update BUILD_STATE.md with
  Phase 6 deliverables and complete Phases 1-6 checklist.

Verified: build_runner 44 outputs, flutter analyze --fatal-infos clean,
flutter test 123/123, flutter build web and flutter build bundle succeed.
This commit is contained in:
Mike Kell 2026-10-16 14:57:55 -04:00
parent 747d917aed
commit 51364a8f4f
21 changed files with 1802 additions and 134 deletions

4
.env.example Normal file
View File

@ -0,0 +1,4 @@
# Copy to .env and fill in your Supabase project values.
# The file must exist for asset bundling even when Supabase is not configured.
SUPABASE_URL=https://YOUR_PROJECT.supabase.co
SUPABASE_PUBLISHABLE_KEY=sb_publishable_replace_me

58
.forgejo/workflows/ci.yml Normal file
View File

@ -0,0 +1,58 @@
# CI pipeline for git.kellsupport.com/mtkell/glp-companion
#
# Runs on Forgejo Actions (GitHub Actions-compatible syntax; also usable as a
# reference for Woodpecker pipelines). Every job executes inside the standard
# Flutter container so the runner only needs Docker.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build:
name: Analyze, test & build
runs-on: ubuntu-latest
container:
image: ghcr.io/cirruslabs/flutter:stable
steps:
# 1. Checkout the repository at the triggering ref.
- name: Checkout repository
uses: actions/checkout@v4
# 2. Dummy runtime configuration: `pubspec.yaml` bundles `.env` as an
# asset, so builds fail without it. The values below are inert
# placeholders — real credentials live in runner secrets / .env.local.
- name: Create mock .env
run: |
cat > .env <<'EOF'
SUPABASE_URL=https://placeholder.supabase.co
SUPABASE_PUBLISHABLE_KEY=sb_publishable_ci_placeholder
EOF
# 3. Resolve Dart/Flutter dependencies.
- name: Install dependencies
run: flutter pub get
# 4. Regenerate drift table classes (app_database.g.dart) so schema
# changes are validated against the checked-in output.
- name: Run drift code generator
run: dart run build_runner build --delete-conflicting-outputs
# 5. Static analysis with infos treated as failures.
- name: Static analysis
run: flutter analyze --fatal-infos
# 6. Unit, widget, and integration suites with coverage instrumentation.
- name: Run test suite
run: flutter test --coverage
# 7. Build validation across responsive form factors: the asset bundle
# (all native targets) and the web release (Wasm/CanvasKit).
- name: Build sanity - asset bundle
run: flutter build bundle
- name: Build sanity - web
run: flutter build web --no-tree-shake-icons

9
.gitignore vendored
View File

@ -36,7 +36,6 @@ migrate_working_dir/
/build/ /build/
# Web specific # Web specific
lib/generated_plugin_registrant.dart
# Symbol processing related # Symbol processing related
undecorated_symbol_map.json undecorated_symbol_map.json
@ -88,3 +87,11 @@ macos/Flutter/ephemeral/
*.keystore *.keystore
*.key *.key
*.p12 *.p12
# The environment template is safe to commit (override the *.env.* rule)
!.env.example
# Local verification scratch output (analyze/codegen/test logs)
analyze_*.txt
codegen_*.txt
test_out.txt

View File

@ -1,8 +1,8 @@
# Build Execution Ledger # Build Execution Ledger
## Active Target ## Active Target
- **Current Milestone:** Phase 5: Pantry Delta Engine & Auto-Shopping Generator — COMPLETE - **Current Milestone:** Phase 6: Cross-Platform Hardening, Integration Tests & Forgejo CI/CD — COMPLETE
- **Status:** Phase 5 Complete (next: Phase 6) - **Status:** Phase 6 Complete (release-ready: all phases 1–6 done)
## Phase Checklist ## Phase Checklist
- [x] Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB) - [x] Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB)
@ -10,7 +10,7 @@
- [x] Phase 3: Nutrition, Protein Target Tracking, HealthGateway Sync - [x] Phase 3: Nutrition, Protein Target Tracking, HealthGateway Sync
- [x] Phase 4: Recipe Web Scraper Engine & Meal Planning Calendar - [x] Phase 4: Recipe Web Scraper Engine & Meal Planning Calendar
- [x] Phase 5: Pantry Delta Engine & Auto-Shopping Generator - [x] Phase 5: Pantry Delta Engine & Auto-Shopping Generator
- [ ] Phase 6: Hardening, Native Desktop/Web Polish, CI/CD - [x] Phase 6: Hardening, Native Desktop/Web Polish, CI/CD
## Phase 2 Deliverables (2026-10-16) ## Phase 2 Deliverables (2026-10-16)
- [x] Domain: `DoseEntry` entity, 12-quadrant `InjectionSite` rotation model, `Glp1Compound` value object (Sema 168h / Tirze 120h / Lira 13h), `IMedicationRepository` contract. - [x] Domain: `DoseEntry` entity, 12-quadrant `InjectionSite` rotation model, `Glp1Compound` value object (Sema 168h / Tirze 120h / Lira 13h), `IMedicationRepository` contract.
@ -53,8 +53,21 @@
- [x] Presentation: `PantryScreen` refactor — Inventory/Shopping tab header with remaining-count badge, Aisles/Expiration segmented control, categorized tiles with +/− counters and Expired/expiring badges, swipe-to-delete on both lists, prominent "Sync with Meal Plan" banner with run summary + snackbar, auto-row "From: <recipe>" origin chips, filter segments, check-off → pantry prompt, and Add Pantry Item / Add Shopping Item dialogs. - [x] Presentation: `PantryScreen` refactor — Inventory/Shopping tab header with remaining-count badge, Aisles/Expiration segmented control, categorized tiles with +/− counters and Expired/expiring badges, swipe-to-delete on both lists, prominent "Sync with Meal Plan" banner with run summary + snackbar, auto-row "From: <recipe>" origin chips, filter segments, check-off → pantry prompt, and Add Pantry Item / Add Shopping Item dialogs.
- [x] Tests: 10 new tests in `test/features/pantry/delta_calculator_test.dart` — the three required scenarios (300 g vs 800 g → +500 g entry; sufficient stock → no entry; manual rows preserved across repeated syncs) plus stale auto cleanup, cross-recipe plural/unit normalization (4 eggs + 2 egg − 2 → +4 egg), lb/oz scale unification (0.75 lb), check-off → pantry transfer (merge + aisle-inferred create), end-to-end `syncWithMealPlan`, and inventory canonicalization/quick-adjust clamp. - [x] Tests: 10 new tests in `test/features/pantry/delta_calculator_test.dart` — the three required scenarios (300 g vs 800 g → +500 g entry; sufficient stock → no entry; manual rows preserved across repeated syncs) plus stale auto cleanup, cross-recipe plural/unit normalization (4 eggs + 2 egg − 2 → +4 egg), lb/oz scale unification (0.75 lb), check-off → pantry transfer (merge + aisle-inferred create), end-to-end `syncWithMealPlan`, and inventory canonicalization/quick-adjust clamp.
## Active Verification Gates ## Phase 6 Deliverables (2026-10-16)
- [x] `flutter pub run build_runner build --delete-conflicting-outputs` - [x] Platform: `HealthGateway` hardened — `IHealthGateway` factory now accepts an `ILocalBiometricReader` (core contract); mobile driver guards with `kIsWeb` + `defaultTargetPlatform` + `dart:io` cross-check, probes Health Connect SDK availability (`getHealthConnectSdkStatus`) and degrades with a debug warning when missing/disabled, validates `READ_WEIGHT`/`READ_STEPS`/`READ_TOTAL_CALORIES_BURNED`/`READ_ACTIVE_CALORIES_BURNED` permissions via `hasPermissions`/`requestAuthorization` before every read, and keeps all HealthKit entitlement failures inside `try/catch` (false + warning, never an unhandled exception).
- [x] `flutter analyze` (Zero issues — `No issues found!`) - [x] Platform: fallback driver (`health_gateway_fallback.dart`) now serves locally logged biometrics through an injected `ILocalBiometricReader` (wired to the drift cache by `DriftLocalBiometricReader` in the health_sync controller) and emits a debug warning instead of attempting native SDK calls; Android manifest declares the four Health Connect `READ_*` permissions matching the gateway request set.
- [x] `flutter test` (99/99 passing) - [x] Security: `CryptoManager` platform-safe vault config — Android `AndroidOptions(encryptedSharedPreferences: true)`, iOS/macOS `KeychainAccessibility.first_unlock` (Darwin Keychain options), Windows DPAPI-sealed file, Linux libsecret; vault failures degrade to an ephemeral in-memory SQLCipher key instead of crashing.
- [x] Testability: `isAuthenticatedProvider` extracted in `app_router.dart` so integration tests can simulate authenticated/unauthenticated sessions without booting Supabase.
- [x] Tests: `test/features/medication/serum_calculator_test.dart` (11 tests — semaglutide 168 h / tirzepatide 120 h weekly steady-state peaks/troughs vs. closed-form geometric series, monotonic accumulation, peak-to-trough fluctuation ratios, multi-dose superposition, brand-alias resolution, titration ladder, washout).
- [x] Tests: `recipe_scraper_test.dart` extended with real-world fixtures (`test/fixtures/recipes/bbc_good_food_style.html` root-level payload, `allrecipes_style.html` multi-script complex `@graph` with `HowToSection` nesting, list-valued yield/image) — 2 new fixture tests.
- [x] Tests: `delta_calculator_test.dart` extended with a unit-conversion group (kg+g dominant-unit reporting, l+ml volume unification, mass/volume dimension-mismatch guard, unit-synonym/plural canonicalization with cross-dimension stock refusal) — 4 new tests.
- [x] Tests: `test/integration/app_navigation_test.dart` (4 tests — navigation-rail switching across `/medication`, `/nutrition`, `/meal-planner`, `/pantry` under authenticated and dev-bypass conditions, mobile bottom-bar switching, unauthenticated bounce to `/login`) and `test/features/nutrition/nutrition_widget_test.dart` (3 tests — protein/hydration `LinearProgressIndicator` values and labels updating reactively on food/water logs, goal-met chips).
- [x] CI/CD: `.forgejo/workflows/ci.yml` for `git.kellsupport.com/mtkell/glp-companion` (GitHub Actions-compatible syntax for Forgejo Actions) — push to `main` + all PRs in `ghcr.io/cirruslabs/flutter:stable`; checkout → mock `.env` → `flutter pub get` → `dart run build_runner build --delete-conflicting-outputs` → `flutter analyze --fatal-infos` → `flutter test --coverage` → `flutter build bundle` + `flutter build web`.
- [x] Cleanup: dead `lib/features/recipes/data/recipe_scraper.dart` (unreferenced Phase-0 scaffold superseded by `ScrapeRecipeFromUrl`) removed with its empty directory; zero TODO/FIXME markers in `lib/` or `test/`.
- [x] Docs: `README.md` rewritten (architectural overview + directory layout, local dev prerequisites incl. VS Code/Cline setup and `.env` configuration via new `.env.example`, SQLCipher AES-256 key lifecycle, CI pipeline details); `BUILD_STATE.md` Phase 1–6 checkboxes complete.
## Active Verification Gates (Phase 6, 2026-10-16)
- [x] `dart run build_runner build --delete-conflicting-outputs` (44 outputs written)
- [x] `flutter analyze --fatal-infos` (Zero issues — `No issues found!`)
- [x] `flutter test` (123/123 passing)
- [x] `flutter build web --no-tree-shake-icons` (conditional import keeps `package:health`/`dart:io` out of the web bundle) - [x] `flutter build web --no-tree-shake-icons` (conditional import keeps `package:health`/`dart:io` out of the web bundle)

193
README.md
View File

@ -1,17 +1,188 @@
# glpcompanion # GLP-1 Companion
A new Flutter project. Cross-platform GLP-1 medication, nutrition, and pantry tracking companion for
people on Semaglutide (Ozempic/Wegovy), Tirzepatide (Mounjaro/Zepbound), and
Liraglutide (Saxenda/Victoza) therapy.
## Getting Started **Targets:** Android · iOS · Web · macOS · Windows
**Stack:** Flutter 3.x / Dart 3.x · Riverpod · GoRouter · Drift (SQLCipher) · Supabase Auth
This project is a starting point for a Flutter application. ## Feature highlights
A few resources to get you started if this is your first Flutter project: - **Pharmacokinetics & dose logistics** — single-compartment serum-level
modeling (`C(t) = Σ D_i · 0.5^((t−t_i)/t_half)`) with per-compound
half-lives (Sema 168 h / Tirze 120 h / Lira 13 h), titration logging, a
12-quadrant injection-site rotation model, and offline dose reminders.
- **Lean-mass preservation** — daily protein (100 g) and hydration (96 oz)
targets with reactive progress meters and a weight-trend tracker.
- **Recipe scraping & meal planning** — schema.org/JSON-LD recipe importer
(`@graph`, `HowToSection`, ISO-8601 durations, OpenGraph fallback), weekly
meal-plan calendar, and portion-aware macro rollups.
- **Pantry delta engine** — canonical ingredient keys, dimension-aware unit
conversions (mass/volume/count), and one-tap "Sync with Meal Plan" that
generates, refreshes, and clears a shopping list while preserving manual
rows.
- **Decoupled biometric ingestion** — `HealthGateway` wraps Health Connect
(Android) and HealthKit (iOS) behind a platform guard; Windows/macOS/Web
degrade to manual entry served from the encrypted local cache.
- **High-privacy persistence** — SQLCipher AES-256 encrypted drift database
with an OS-vaulted master key; passwordless Supabase auth (magic links,
OTP) plus Google/Apple/Microsoft OAuth.
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) ## Architecture
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) Feature-first Clean Architecture — every bounded context owns its domain,
data, and presentation layers, and `core/` never imports feature code:
```text
lib/
├── app/ # Bootstrap, GoRouter routes + auth guard, adaptive scaffold
├── core/ # Shared foundation (no feature imports)
│ ├── database/ # Drift schema (SQLCipher) + conditional web/native connections
│ ├── error/ # Failure models
│ ├── network/ # Supabase availability probe
│ ├── platform/ # HealthGateway, notification service (OS adapters)
│ ├── security/ # CryptoManager (SQLCipher key vault)
│ ├── theme/ # Material 3 / Cupertino adaptive theming
│ └── utils/ # Date/time helpers, UUID generation
└── features/ # Modular bounded contexts
├── auth/ # Passwordless SSO, OAuth, debug sign-in bypass
├── health_sync/ # Biometric cache, trends, HealthGateway sync
├── meal_planner/ # JSON-LD scraper, recipe catalog, weekly calendar
├── medication/ # Doses, injection rotation, serum modeling
├── nutrition/ # Food/hydration logs, protein + hydration targets
└── pantry/ # Inventory, shopping delta engine, unit conversions
```
Each feature follows the same internal layout:
```text
<feature>/
├── domain/ # Pure Dart: entities, value objects, repository
│ ├── entities/ # interfaces, use cases (ZERO framework imports)
│ ├── value_objects/
│ ├── repositories/
│ └── usecases/
├── data/ # Drift-backed repository implementations
└── presentation/ # Riverpod notifiers, screens, adaptive widgets
```
Platform-awareness rules:
- Presentation code never imports `dart:io`-bound packages; OS APIs sit
behind core contracts (`IHealthGateway`, `INotificationService`) selected
by conditional imports and `kIsWeb` / `defaultTargetPlatform` guards.
- Web/desktop builds gracefully degrade (manual entry, no-op reminders)
instead of crashing on missing platform channels.
- Responsive layout: mobile (<600 dp) uses `NavigationBar`, desktop/web
(≥1024 dp) uses a `NavigationRail`; Apple targets lean Cupertino.
## Getting started
### Prerequisites
- **Flutter SDK ≥ 3.16** (stable channel; Dart 3.x) — `flutter doctor` must
be clean for the platforms you intend to run.
- **VS Code + Cline** (or another MCP-capable agent): the repository ships
`.clinerules` with the architecture contract and `BUILD_STATE.md` as the
milestone ledger. Ask the agent to "read BUILD_STATE.md" first so it
resumes at the correct phase.
- **A Supabase project** (optional for local review): without it the app
boots into the login screen; debug builds can tap
*Continue Without Signing In* to explore with an in-memory session.
### Environment configuration
`pubspec.yaml` bundles a `.env` asset, so the file must exist before
`flutter run`/`flutter build`:
```bash
cp .env.example .env # then fill in your project values
```
```dotenv
SUPABASE_URL=https://YOUR_PROJECT.supabase.co
SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
```
Missing values are tolerated at runtime (the Supabase client stays
uninitialized and the app falls back to the dev bypass), but the file itself
must exist for asset bundling.
### Run it
```bash
flutter pub get
dart run build_runner build --delete-conflicting-outputs # regenerate drift code
flutter run -d windows # or: chrome / macos / android / ios
```
### Verify
```bash
flutter analyze --fatal-infos # zero tolerance lint gate
flutter test --coverage # unit + widget + integration suites
flutter build web --no-tree-shake-icons
```
## Database encryption (SQLCipher AES-256)
All health markers (doses, weight, nutrition logs, pantry) live in a
**Drift** database compiled as **SQLCipher** through the `sqlite3` build-hook
define in `pubspec.yaml` (`hooks.user_defines.sqlite3.source: sqlcipher`).
Key lifecycle (`lib/core/security/crypto_manager.dart`):
1. A random 256-bit master key is generated on first launch with
`Random.secure()`.
2. The key is vaulted in the OS secret store with platform-hardened options:
- **Android** — Jetpack Crypto `EncryptedSharedPreferences`
(`encryptedSharedPreferences: true`).
- **iOS / macOS** — Keychain with
`KeychainAccessibility.first_unlock`, so background services can open
the database after a reboot.
- **Windows** — DPAPI-sealed file (`CryptProtectData`) managed by
`flutter_secure_storage`'s FFI backend.
- **Linux** — libsecret / Secret Service (GNOME Keyring).
3. On every open, the key is applied via `PRAGMA key` before drift issues
its first statement (`lib/core/database/connection/native.dart`).
4. If the vault is unavailable (headless CI, locked-down hosts), the
`CryptoManager` degrades to an ephemeral in-memory key instead of
crashing — the database stays encrypted for the session and re-keys on
the next launch.
5. Web builds fall back to WASM SQLite (SQLCipher cannot run in a browser);
treat the web target as a demo surface, not the privacy-grade target.
## CI/CD (git.kellsupport.com/mtkell/glp-companion)
Forgejo Actions workflows live in `.forgejo/workflows/`. The `ci.yml`
pipeline runs on every push to `main` and on all pull requests inside the
`ghcr.io/cirruslabs/flutter:stable` container:
1. `actions/checkout@v4`
2. Mock `.env` with placeholder `SUPABASE_URL` / `SUPABASE_PUBLISHABLE_KEY`
(the real file is git-ignored; the asset must exist for builds)
3. `flutter pub get`
4. `dart run build_runner build --delete-conflicting-outputs` (drift codegen)
5. `flutter analyze --fatal-infos`
6. `flutter test --coverage`
7. Build sanity: `flutter build bundle` + `flutter build web`
Repository: <https://git.kellsupport.com/mtkell/glp-companion>
## Testing strategy
- `test/features/**` — domain unit tests (PK calculator, rotation policy,
unit conversions, delta engine), data-layer tests over in-memory drift,
and controller tests with fakes.
- `test/features/**/**_screen_test.dart`, `test/features/nutrition/
nutrition_widget_test.dart` — widget tests exercising adaptive layouts
and reactive progress meters.
- `test/integration/app_navigation_test.dart` — shell-level navigation
across all core routes under authenticated, dev-bypass, and
unauthenticated conditions, on both rail and bar layouts.
## License
Private project — all rights reserved.
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

View File

@ -3,6 +3,13 @@
re-scheduling of pending injection reminders and notification display. --> re-scheduling of pending injection reminders and notification display. -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/> <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<!-- Health Connect (Android 14+ / Play services): read permissions matching
the HealthGateway request set (weight, steps, total calories burned,
active energy burned). -->
<uses-permission android:name="android.permission.health.READ_WEIGHT"/>
<uses-permission android:name="android.permission.health.READ_STEPS"/>
<uses-permission android:name="android.permission.health.READ_TOTAL_CALORIES_BURNED"/>
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED"/>
<application <application
android:label="glpcompanion" android:label="glpcompanion"
android:name="${applicationName}" android:name="${applicationName}"

View File

@ -19,6 +19,17 @@ const Set<String> _publicPaths = {'/login'};
final _rootNavigatorKey = GlobalKey<NavigatorState>(); final _rootNavigatorKey = GlobalKey<NavigatorState>();
final _shellNavigatorKey = GlobalKey<NavigatorState>(); final _shellNavigatorKey = GlobalKey<NavigatorState>();
/// Whether the current session satisfies the authenticated guard.
///
/// Reads the live GoTrue session when the SDK is initialized and reports
/// `false` otherwise. Extracted into a provider so integration tests can
/// simulate an authenticated (or unauthenticated) session without booting
/// Supabase — they simply override this value.
final isAuthenticatedProvider = Provider<bool>(
(ref) => isSupabaseAvailable &&
Supabase.instance.client.auth.currentSession != null,
);
/// Application router with a reactive authentication guard and dev bypass hook. /// Application router with a reactive authentication guard and dev bypass hook.
final routerProvider = Provider<GoRouter>((ref) { final routerProvider = Provider<GoRouter>((ref) {
final authRefreshNotifier = ValueNotifier<int>(0); final authRefreshNotifier = ValueNotifier<int>(0);
@ -81,8 +92,7 @@ String? _authGuard(BuildContext context, GoRouterState state, Ref ref) {
return null; return null;
} }
final isAuthenticated = isSupabaseAvailable && final isAuthenticated = ref.read(isAuthenticatedProvider);
Supabase.instance.client.auth.currentSession != null;
if (!isAuthenticated && !_publicPaths.contains(location)) { if (!isAuthenticated && !_publicPaths.contains(location)) {
return '/login'; return '/login';

View File

@ -36,14 +36,37 @@ class HealthMetric {
final String unit; final String unit;
} }
/// Read-only access to biometrics that were logged locally on this device.
///
/// Implemented by the health_sync feature over the encrypted drift cache and
/// injected into the fallback gateway so Web/Windows/macOS builds serve
/// locally logged data instead of returning nothing. Declared in `core` to
/// keep the platform layer free of feature imports (architecture rule 2);
/// the dependency points inward: features implement core contracts.
abstract class ILocalBiometricReader {
/// Weight samples in pounds for `[start, end)`, newest first.
Future<List<HealthMetric>> readWeightRange({
required DateTime start,
required DateTime end,
});
/// Active energy burned (kcal) samples for `[start, end)`, newest first.
Future<List<HealthMetric>> readEnergyRange({
required DateTime start,
required DateTime end,
});
}
/// Cross-platform boundary over the OS health ecosystems (architecture rule /// Cross-platform boundary over the OS health ecosystems (architecture rule
/// 2/3: platform-specific packages never leak past this interface). /// 2/3: platform-specific packages never leak past this interface).
/// ///
/// * **Android / iOS:** implemented by `health_gateway_mobile.dart`, which /// * **Android / iOS:** implemented by `health_gateway_mobile.dart`, which
/// wraps the `health` package (Health Connect / HealthKit). /// wraps the `health` package (Health Connect / HealthKit) behind
/// `kIsWeb` / `defaultTargetPlatform` guards.
/// * **Windows / macOS / Web:** implemented by `health_gateway_fallback.dart`, /// * **Windows / macOS / Web:** implemented by `health_gateway_fallback.dart`,
/// a manual-entry mode that never touches platform channels; consumers read /// a manual-entry mode that never touches platform channels; reads are
/// the drift-backed cache through `IHealthSyncRepository` instead. /// served from the drift-backed cache through an injected
/// [ILocalBiometricReader].
abstract class IHealthGateway { abstract class IHealthGateway {
/// Whether this device can read/write native health data. `false` on /// Whether this device can read/write native health data. `false` on
/// Web/Windows/macOS, which signals the UI to offer manual entry. /// Web/Windows/macOS, which signals the UI to offer manual entry.
@ -84,4 +107,8 @@ abstract class IHealthGateway {
/// Platform-appropriate factory: native `health`-backed gateway on /// Platform-appropriate factory: native `health`-backed gateway on
/// Android/iOS, manual-entry fallback on Web/Windows/macOS. /// Android/iOS, manual-entry fallback on Web/Windows/macOS.
IHealthGateway createHealthGateway() => impl.makeHealthGateway(); ///
/// [localReader] backs fallback reads with the locally logged biometric
/// cache; mobile platforms ignore it and query the native store directly.
IHealthGateway createHealthGateway({ILocalBiometricReader? localReader}) =>
impl.makeHealthGateway(localReader: localReader);

View File

@ -1,18 +1,38 @@
import 'package:flutter/foundation.dart';
import 'health_gateway.dart'; import 'health_gateway.dart';
/// Conditional-import entry point used by `health_gateway.dart`. /// Conditional-import entry point used by `health_gateway.dart`.
IHealthGateway makeHealthGateway() => FallbackHealthGateway(); ///
/// [localReader] (usually the drift-backed biometric cache) lets fallback
/// reads serve locally logged data instead of nothing.
IHealthGateway makeHealthGateway({ILocalBiometricReader? localReader}) =>
FallbackHealthGateway(localBiometricReader: localReader);
/// Graceful-degradation gateway for Web, Windows, and macOS (architecture /// Graceful-degradation gateway for Web, Windows, macOS, and Linux
/// rule 3: desktop/web get read-only manual entry until cloud OAuth partners /// (architecture rule 3: desktop/web get read-only manual entry until cloud
/// land in a later phase). /// OAuth partners land in a later phase).
/// ///
/// It never touches a platform channel, so unsupported hardware calls can /// It never touches a platform channel, so unsupported hardware calls can
/// never crash: every read returns an empty list and every write reports /// never crash: permissions are refused, writes are rejected, and reads are
/// `false`. Consumers treat `supportsNativeHealth == false` as the signal to /// served from the injected [ILocalBiometricReader] (the encrypted drift
/// open the manual-entry dialog and serve the drift-backed local cache /// cache of locally logged biometrics) — or from nothing when no reader was
/// (`IHealthSyncRepository`) for reads. /// wired. Consumers treat `supportsNativeHealth == false` as the signal to
/// open the manual-entry dialog.
class FallbackHealthGateway implements IHealthGateway { class FallbackHealthGateway implements IHealthGateway {
FallbackHealthGateway({ILocalBiometricReader? localBiometricReader})
: _localReader = localBiometricReader {
// Debug-only diagnostic: makes the active driver obvious while
// troubleshooting platform issues; `debugPrint` is stripped in release.
debugPrint(
'HealthGateway: native health SDKs are unavailable on this platform - '
'running the manual-entry fallback driver'
'${_localReader == null ? ' without a local biometric cache' : ''}.',
);
}
final ILocalBiometricReader? _localReader;
@override @override
bool get supportsNativeHealth => false; bool get supportsNativeHealth => false;
@ -26,20 +46,41 @@ class FallbackHealthGateway implements IHealthGateway {
Future<List<HealthMetric>> fetchWeightHistory({ Future<List<HealthMetric>> fetchWeightHistory({
required DateTime start, required DateTime start,
required DateTime end, required DateTime end,
}) async => }) =>
const []; _read(() => _localReader?.readWeightRange(start: start, end: end));
@override @override
Future<List<HealthMetric>> fetchActiveEnergyBurned({ Future<List<HealthMetric>> fetchActiveEnergyBurned({
required DateTime start, required DateTime start,
required DateTime end, required DateTime end,
}) async => }) =>
const []; _read(() => _localReader?.readEnergyRange(start: start, end: end));
@override @override
Future<bool> writeWeight({ Future<bool> writeWeight({
required double weightLbs, required double weightLbs,
required DateTime timestamp, required DateTime timestamp,
}) async => }) async {
false; debugPrint(
'HealthGateway: writeWeight ignored in fallback mode - persist through '
'manual entry instead.',
);
return false;
}
/// Delegates to the local reader when one is wired, degrading to an empty
/// result (never throwing) when it is missing or fails.
Future<List<HealthMetric>> _read(
Future<List<HealthMetric>>? Function() read,
) async {
try {
return await read() ?? const [];
} catch (error, stackTrace) {
debugPrintStack(
stackTrace: stackTrace,
label: 'HealthGateway: local biometric cache read failed (non-fatal)',
);
return const [];
}
}
} }

View File

@ -6,23 +6,67 @@ import 'package:health/health.dart';
import 'health_gateway.dart'; import 'health_gateway.dart';
/// Conditional-import entry point used by `health_gateway.dart`. /// Conditional-import entry point used by `health_gateway.dart`.
IHealthGateway makeHealthGateway() => MobileHealthGateway(); ///
/// [localReader] is accepted for factory-signature parity with the fallback
/// driver; native platforms never need it because reads are served by
/// Health Connect / HealthKit directly.
IHealthGateway makeHealthGateway({ILocalBiometricReader? localReader}) =>
MobileHealthGateway();
/// `package:health`-backed gateway for Android (Health Connect) and iOS /// `package:health`-backed gateway for Android (Health Connect) and iOS
/// (HealthKit). /// (HealthKit).
/// ///
/// Every call is guarded twice: statically by [supportsNativeHealth] (so /// Guard layers (architecture rule 2/3 — zero crashes on unsupported
/// Web/Windows/macOS never reach a `MethodChannel` with no registered /// environments):
/// plugin) and defensively by `try/catch` (so a missing Health Connect ///
/// installation or a denied permission degrades to an empty result instead /// 1. **Static:** [supportsNativeHealth] combines `kIsWeb`,
/// of crashing the caller). /// `defaultTargetPlatform`, and the `dart:io` runtime so Web builds never
/// reach a platform channel and desktop test hosts are never tricked by
/// a faked target platform.
/// 2. **Health Connect availability (Android):** the SDK ships outside the
/// OS and may be missing or disabled; the probe degrades to "no data"
/// with a debug warning instead of throwing.
/// 3. **Permissions:** every read re-validates `hasPermissions`; a denied
/// permission degrades to an empty result so the UI offers manual entry.
/// 4. **Defensive:** every native call is wrapped in `try/catch` so missing
/// HealthKit entitlements or hardware quirks can never surface as
/// unhandled exceptions.
class MobileHealthGateway implements IHealthGateway { class MobileHealthGateway implements IHealthGateway {
final Health _health = Health(); final Health _health = Health();
Future<void>? _configuration; Future<void>? _configuration;
/// Health Connect SDK availability, probed once per gateway instance.
Future<bool>? _healthConnectReady;
/// Read set requested from Health Connect (Android), mirroring the
/// `READ_*` permissions declared in the Android manifest: weight plus the
/// activity metrics consumed by the nutrition dashboard.
static const List<HealthDataType> _androidReadTypes = [
HealthDataType.WEIGHT,
HealthDataType.STEPS,
HealthDataType.TOTAL_CALORIES_BURNED,
HealthDataType.ACTIVE_ENERGY_BURNED,
];
/// Read set requested from HealthKit (iOS); requires the HealthKit
/// entitlement, which degrades gracefully (via [requestPermissions])
/// when absent from the signing profile.
static const List<HealthDataType> _iosReadTypes = [
HealthDataType.WEIGHT,
HealthDataType.ACTIVE_ENERGY_BURNED,
];
@override @override
bool get supportsNativeHealth => bool get supportsNativeHealth {
!kIsWeb && (Platform.isAndroid || Platform.isIOS); if (kIsWeb) return false;
// Framework-reported target platform (authoritative in profile/release)...
final targetIsMobile = defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS;
// ...cross-checked against the actual IO runtime, so debug builds that
// fake the target platform (widget tests default to Android on any host)
// still degrade to the fallback driver instead of probing dead channels.
return targetIsMobile && (Platform.isAndroid || Platform.isIOS);
}
@override @override
NativeHealthPlatform? get nativePlatform { NativeHealthPlatform? get nativePlatform {
@ -36,19 +80,63 @@ class MobileHealthGateway implements IHealthGateway {
/// before any read/write call. /// before any read/write call.
Future<void> _configure() => _configuration ??= _health.configure(); Future<void> _configure() => _configuration ??= _health.configure();
/// The read types backing this gateway on the current platform.
List<HealthDataType> get _readTypes =>
Platform.isIOS ? _iosReadTypes : _androidReadTypes;
List<HealthDataAccess> get _readPermissions =>
List<HealthDataAccess>.filled(_readTypes.length, HealthDataAccess.READ);
/// Android-only guard: Health Connect may not be installed or may be
/// disabled in system settings; either way the gateway degrades to "no
/// data" with a debug warning instead of crashing.
Future<bool> _ensureHealthConnectReady() =>
_healthConnectReady ??= _probeHealthConnect();
Future<bool> _probeHealthConnect() async {
try {
final status = await _health.getHealthConnectSdkStatus();
if (status == HealthConnectSdkStatus.sdkAvailable) return true;
debugPrint(
'HealthGateway: Health Connect unavailable (status: $status) - '
'degrading to manual entry.',
);
return false;
} catch (error, stackTrace) {
debugPrintStack(
stackTrace: stackTrace,
label:
'HealthGateway: Health Connect availability probe failed (non-fatal)',
);
return false;
}
}
@override @override
Future<bool> requestPermissions() async { Future<bool> requestPermissions() async {
if (!supportsNativeHealth) return false; if (!supportsNativeHealth) return false;
try { try {
await _configure(); await _configure();
if (Platform.isAndroid && !await _ensureHealthConnectReady()) {
return false;
}
final alreadyGranted = await _health.hasPermissions(
_readTypes,
permissions: _readPermissions,
);
if (alreadyGranted == true) return true;
// Missing HealthKit entitlements or a revoked Health Connect grant
// surface as `false` (or an exception) here; both degrade to `false`.
return await _health.requestAuthorization( return await _health.requestAuthorization(
const [HealthDataType.WEIGHT, HealthDataType.ACTIVE_ENERGY_BURNED], _readTypes,
permissions: const [HealthDataAccess.READ, HealthDataAccess.READ], permissions: _readPermissions,
); );
} catch (error, stackTrace) { } catch (error, stackTrace) {
debugPrintStack( debugPrintStack(
stackTrace: stackTrace, stackTrace: stackTrace,
label: 'health requestPermissions failed (non-fatal)', label: 'HealthGateway: requestPermissions failed (non-fatal)',
); );
return false; return false;
} }
@ -58,7 +146,7 @@ class MobileHealthGateway implements IHealthGateway {
Future<List<HealthMetric>> fetchWeightHistory({ Future<List<HealthMetric>> fetchWeightHistory({
required DateTime start, required DateTime start,
required DateTime end, required DateTime end,
}) async => }) =>
_fetch( _fetch(
type: HealthDataType.WEIGHT, type: HealthDataType.WEIGHT,
start: start, start: start,
@ -71,7 +159,7 @@ class MobileHealthGateway implements IHealthGateway {
Future<List<HealthMetric>> fetchActiveEnergyBurned({ Future<List<HealthMetric>> fetchActiveEnergyBurned({
required DateTime start, required DateTime start,
required DateTime end, required DateTime end,
}) async => }) =>
_fetch( _fetch(
type: HealthDataType.ACTIVE_ENERGY_BURNED, type: HealthDataType.ACTIVE_ENERGY_BURNED,
start: start, start: start,
@ -88,6 +176,9 @@ class MobileHealthGateway implements IHealthGateway {
if (!supportsNativeHealth) return false; if (!supportsNativeHealth) return false;
try { try {
await _configure(); await _configure();
if (Platform.isAndroid && !await _ensureHealthConnectReady()) {
return false;
}
return await _health.writeHealthData( return await _health.writeHealthData(
value: weightLbs * 0.45359237, value: weightLbs * 0.45359237,
unit: HealthDataUnit.KILOGRAM, unit: HealthDataUnit.KILOGRAM,
@ -99,14 +190,15 @@ class MobileHealthGateway implements IHealthGateway {
} catch (error, stackTrace) { } catch (error, stackTrace) {
debugPrintStack( debugPrintStack(
stackTrace: stackTrace, stackTrace: stackTrace,
label: 'health writeWeight failed (non-fatal)', label: 'HealthGateway: writeWeight failed (non-fatal)',
); );
return false; return false;
} }
} }
/// Shared query path: read [type] over `[start, end)`, keep only numeric /// Shared query path: re-validate readiness + permission, read [type] over
/// values, normalize each sample via [normalize], and sort newest-first. /// `[start, end)`, keep only numeric values, normalize each sample via
/// [normalize], and sort newest-first. Never throws.
Future<List<HealthMetric>> _fetch({ Future<List<HealthMetric>> _fetch({
required HealthDataType type, required HealthDataType type,
required DateTime start, required DateTime start,
@ -117,6 +209,24 @@ class MobileHealthGateway implements IHealthGateway {
if (!supportsNativeHealth) return const []; if (!supportsNativeHealth) return const [];
try { try {
await _configure(); await _configure();
if (Platform.isAndroid && !await _ensureHealthConnectReady()) {
return const [];
}
// Reads never prompt: a missing grant routes through
// `requestPermissions()` (invoked by the sync flow) instead.
final granted = await _health.hasPermissions(
[type],
permissions: const [HealthDataAccess.READ],
);
if (granted != true) {
debugPrint(
'HealthGateway: $type query skipped - permission not granted '
'(call requestPermissions() first).',
);
return const [];
}
final points = await _health.getHealthDataFromTypes( final points = await _health.getHealthDataFromTypes(
types: [type], types: [type],
startTime: start, startTime: start,
@ -141,7 +251,7 @@ class MobileHealthGateway implements IHealthGateway {
// degrade to "no data" so the UI can offer manual entry instead. // degrade to "no data" so the UI can offer manual entry instead.
debugPrintStack( debugPrintStack(
stackTrace: stackTrace, stackTrace: stackTrace,
label: 'health $type query failed (non-fatal)', label: 'HealthGateway: $type query failed (non-fatal)',
); );
return const []; return const [];
} }

View File

@ -1,25 +1,101 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Platform-hardened vault for the SQLCipher AES-256 master key.
///
/// Every target stores the key in its native secret store with safe
/// defaults:
///
/// | Platform | Backend | Hardening |
/// |-----------|----------------------------------|----------------------------------------|
/// | Android | Jetpack Crypto `EncryptedSharedPreferences` | `encryptedSharedPreferences: true` |
/// | iOS | Keychain | `KeychainAccessibility.first_unlock` |
/// | macOS | Keychain | `KeychainAccessibility.first_unlock` |
/// | Windows | DPAPI-encrypted JSON file (`CryptProtectData`) | plugin backend default |
/// | Linux | libsecret / Secret Service | plugin backend default |
/// | Web | Browser-scoped obfuscated store | never used for the SQLCipher key |
///
/// `first_unlock` (rather than `when_unlocked`) lets background services
/// (health sync, dose reminders) open the encrypted database after a reboot
/// without requiring the device to be unlocked again.
///
/// When the platform vault is unavailable — headless Linux without a Secret
/// Service, locked-down CI runners, exotic embedders — the manager degrades
/// to an ephemeral in-memory key instead of crashing: the database stays
/// encrypted for the current process run and re-keys (re-syncs) on the next
/// launch.
class CryptoManager { class CryptoManager {
static const _databaseKeyName = 'glp1_sqlcipher_master_key'; static const _databaseKeyName = 'glp1_sqlcipher_master_key';
final FlutterSecureStorage _secureStorage;
/// Platform-safe vault configuration (see the class documentation).
static const FlutterSecureStorage _platformVault = FlutterSecureStorage(
// Android: AES-256 EncryptedSharedPreferences (API 23+).
aOptions: AndroidOptions(encryptedSharedPreferences: true),
// iOS: Keychain readable after the first post-boot unlock.
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
// macOS: same Keychain accessibility contract as iOS.
mOptions: MacOsOptions(accessibility: KeychainAccessibility.first_unlock),
// Windows: DPAPI-sealed file managed by the plugin's FFI backend.
wOptions: WindowsOptions.defaultOptions,
// Linux: libsecret (Secret Service / GNOME Keyring) backend.
lOptions: LinuxOptions.defaultOptions,
);
CryptoManager({FlutterSecureStorage? secureStorage}) CryptoManager({FlutterSecureStorage? secureStorage})
: _secureStorage = secureStorage ?? const FlutterSecureStorage(); : _secureStorage = secureStorage ?? _platformVault;
final FlutterSecureStorage _secureStorage;
/// Session-scoped replacement key used only when the platform vault is
/// unavailable (see the class documentation).
String? _ephemeralKey;
/// Returns the persistent SQLCipher master key, generating and vaulting it
/// on first run. Never throws: a vault failure falls back to an ephemeral
/// in-memory key (database usable until the process exits).
Future<String> getOrCreateDatabaseEncryptionKey() async { Future<String> getOrCreateDatabaseEncryptionKey() async {
final existingKey = await _secureStorage.read(key: _databaseKeyName); final existingKey =
await _tryOrNull(() => _secureStorage.read(key: _databaseKeyName));
if (existingKey != null && existingKey.isNotEmpty) { if (existingKey != null && existingKey.isNotEmpty) {
return existingKey; return existingKey;
} }
final newKey = _generateKey();
final stored = await _tryOrNull(() async {
await _secureStorage.write(key: _databaseKeyName, value: newKey);
return true;
});
if (stored == true) return newKey;
debugPrintStack(
label: 'Secure vault unavailable - using an ephemeral in-memory '
'SQLCipher key for this session (data re-keys on next launch).',
);
return _ephemeralKey ??= newKey;
}
/// 256-bit cryptographically random key, base64url-encoded for safe
/// embedding in `PRAGMA key`.
static String _generateKey() {
final random = Random.secure(); final random = Random.secure();
final values = List<int>.generate(32, (i) => random.nextInt(256)); final values = List<int>.generate(32, (i) => random.nextInt(256));
final newKey = base64UrlEncode(values); return base64UrlEncode(values);
}
await _secureStorage.write(key: _databaseKeyName, value: newKey); /// Runs a vault operation, returning `null` (instead of throwing) on any
return newKey; /// platform failure so callers can degrade gracefully.
Future<T?> _tryOrNull<T>(Future<T> Function() operation) async {
try {
return await operation();
} catch (error, stackTrace) {
debugPrintStack(
stackTrace: stackTrace,
label: 'Secure vault access failed (non-fatal): $error',
);
return null;
}
} }
} }

View File

@ -18,11 +18,61 @@ final healthSyncRepositoryProvider = Provider<IHealthSyncRepository>(
/// Cross-platform health boundary (HealthKit / Health Connect on mobile, /// Cross-platform health boundary (HealthKit / Health Connect on mobile,
/// manual-entry fallback on Web/Windows/macOS). /// manual-entry fallback on Web/Windows/macOS).
/// ///
/// The fallback driver receives the encrypted drift cache as its
/// [ILocalBiometricReader] so desktop/web reads serve locally logged
/// biometrics instead of returning nothing.
///
/// Overridable in tests with a fake gateway. /// Overridable in tests with a fake gateway.
final healthGatewayProvider = Provider<IHealthGateway>( final healthGatewayProvider = Provider<IHealthGateway>(
(ref) => createHealthGateway(), (ref) => createHealthGateway(
localReader: DriftLocalBiometricReader(
ref.watch(healthSyncRepositoryProvider),
),
),
); );
/// Adapts the drift-backed biometric cache ([IHealthSyncRepository]) to the
/// core [ILocalBiometricReader] contract, translating domain entries into
/// display-unit [HealthMetric] samples (lb / kcal).
class DriftLocalBiometricReader implements ILocalBiometricReader {
const DriftLocalBiometricReader(this._repository);
final IHealthSyncRepository _repository;
@override
Future<List<HealthMetric>> readWeightRange({
required DateTime start,
required DateTime end,
}) async {
final entries = await _repository.getBiometrics(from: start, to: end);
return [
for (final entry in entries)
HealthMetric(
timestamp: entry.timestamp,
value: entry.weightLbs,
unit: 'lb',
),
];
}
@override
Future<List<HealthMetric>> readEnergyRange({
required DateTime start,
required DateTime end,
}) async {
final entries = await _repository.getBiometrics(from: start, to: end);
return [
for (final entry in entries)
if (entry.activeCaloriesBurned != null)
HealthMetric(
timestamp: entry.timestamp,
value: entry.activeCaloriesBurned!,
unit: 'kcal',
),
];
}
}
/// Immutable snapshot of the weight/biometric tracking state. /// Immutable snapshot of the weight/biometric tracking state.
class BiometricState { class BiometricState {
const BiometricState({ const BiometricState({

View File

@ -80,13 +80,17 @@ class _ProteinCard extends StatelessWidget {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( // Flexible keeps the title from overflowing narrow phones
// (and wide-glyph test fonts) when the goal chip is present.
Flexible(
child: Text(
'Daily Protein Target', 'Daily Protein Target',
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.titleMedium .titleMedium
?.copyWith(fontWeight: FontWeight.bold), ?.copyWith(fontWeight: FontWeight.bold),
), ),
),
if (reachedGoal) if (reachedGoal)
Chip( Chip(
label: const Text('Goal met'), label: const Text('Goal met'),
@ -378,7 +382,12 @@ class _WeightTrackerCard extends ConsumerWidget {
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( // Wrap (instead of Row) so the action buttons stack cleanly on very
// narrow phones instead of overflowing the card.
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
FilledButton.icon( FilledButton.icon(
onPressed: state.isSyncing onPressed: state.isSyncing
@ -396,7 +405,6 @@ class _WeightTrackerCard extends ConsumerWidget {
), ),
label: Text(state.isSyncing ? 'Syncing...' : 'Log Weight / Sync'), label: Text(state.isSyncing ? 'Syncing...' : 'Log Weight / Sync'),
), ),
const SizedBox(width: 8),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () => showLogWeightDialog(context), onPressed: () => showLogWeightDialog(context),
icon: const Icon(Icons.edit_outlined, size: 18), icon: const Icon(Icons.edit_outlined, size: 18),

View File

@ -1,61 +0,0 @@
import 'dart:convert';
import 'package:html/parser.dart' as html_parser;
import 'package:http/http.dart' as http;
class ScrapedRecipe {
final String title;
final List<String> ingredients;
final List<String> instructions;
final double? proteinGrams;
ScrapedRecipe({
required this.title,
required this.ingredients,
required this.instructions,
this.proteinGrams,
});
}
class RecipeScraperService {
Future<ScrapedRecipe?> extractFromUrl(String url) async {
final response = await http.get(Uri.parse(url));
if (response.statusCode != 200) return null;
final document = html_parser.parse(response.body);
final scriptTags =
document.querySelectorAll('script[type="application/ld+json"]');
for (final tag in scriptTags) {
try {
final data = jsonDecode(tag.text);
if (data is Map<String, dynamic> && data['@type'] == 'Recipe') {
return _parseJsonLd(data);
} else if (data is List) {
final recipe = data.firstWhere((e) => e['@type'] == 'Recipe',
orElse: () => null);
if (recipe != null) return _parseJsonLd(recipe);
}
} catch (_) {
continue;
}
}
return null;
}
ScrapedRecipe _parseJsonLd(Map<String, dynamic> json) {
return ScrapedRecipe(
title: json['name'] ?? 'Untitled Recipe',
ingredients: List<String>.from(json['recipeIngredient'] ?? []),
instructions: (json['recipeInstructions'] as List? ?? [])
.map((step) =>
step is Map ? step['text']?.toString() ?? '' : step.toString())
.toList(),
proteinGrams: double.tryParse(
json['nutrition']?['proteinContent']
?.toString()
.replaceAll(RegExp(r'[^0-9.]'), '') ??
'',
),
);
}
}

View File

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:glp1_companion/features/meal_planner/domain/usecases/scrape_recipe_from_url.dart'; import 'package:glp1_companion/features/meal_planner/domain/usecases/scrape_recipe_from_url.dart';
@ -299,4 +301,85 @@ void main() {
); );
}); });
}); });
group('Real-world sample fixtures', () {
/// Loads a captured page shape from `test/fixtures/recipes/`.
String fixture(String name) =>
File('test/fixtures/recipes/$name').readAsStringSync();
test('parses a BBC Good Food-style root-level Recipe payload', () {
final recipe = useCase.parseHtml(
fixture('bbc_good_food_style.html'),
sourceUrl: 'https://www.bbcgoodfood.com/recipes/classic-chicken-curry',
);
expect(recipe, isNotNull);
expect(recipe!.title, 'Classic chicken curry');
expect(recipe.description, startsWith('A simple, healthy curry'));
expect(
recipe.imageUrl,
endsWith('styles/hero/public/curry.jpg'),
);
expect(recipe.prepTimeMinutes, 15);
expect(recipe.cookTimeMinutes, 35);
expect(recipe.servings, 4);
// Published nutrition strings carry units; numeric parsing strips them.
expect(recipe.macrosPerServing.calories, 387);
expect(recipe.macrosPerServing.proteinGrams, 34);
expect(recipe.macrosPerServing.carbsGrams, 20);
expect(recipe.macrosPerServing.fatGrams, 14);
expect(recipe.ingredients, hasLength(5));
final curryPaste = recipe.ingredients[2];
expect(curryPaste.quantity, 3);
expect(curryPaste.unit, 'tbsp');
expect(curryPaste.parsedItemName, 'curry paste');
final yogurt = recipe.ingredients[3];
expect(yogurt.quantity, 100);
expect(yogurt.unit, 'g');
expect(yogurt.parsedItemName, 'yogurt');
expect(recipe.instructions, hasLength(3));
expect(recipe.instructions.first, 'Fry the onion until golden.');
});
test('skips non-recipe @graph scripts and parses the complex one', () {
final recipe = useCase.parseHtml(
fixture('allrecipes_style.html'),
sourceUrl: 'https://example.eats/recipes/turkey-meatballs',
);
expect(recipe, isNotNull);
expect(recipe!.title, 'Weeknight Turkey Meatballs');
// List-valued yield: ["6 servings"].
expect(recipe.servings, 6);
expect(recipe.prepTimeMinutes, 20);
expect(recipe.cookTimeMinutes, 25);
// First entry of the mixed string/object image array wins.
expect(
recipe.imageUrl,
'https://example.eats/img/turkey-meatballs-1000.jpg',
);
expect(recipe.macrosPerServing.calories, 321);
expect(recipe.macrosPerServing.proteinGrams, 29);
expect(recipe.ingredients, hasLength(5));
// Decimal metric weight in imperial clothing: "1.25 lb ground turkey".
expect(recipe.ingredients[0].quantity, 1.25);
expect(recipe.ingredients[0].unit, 'lb');
expect(recipe.ingredients[0].parsedItemName, 'ground turkey');
expect(recipe.ingredients[1].quantity, 0.5);
expect(recipe.ingredients[1].unit, 'cup');
// Nested HowToSections flatten into plain steps in document order.
expect(recipe.instructions, [
'Heat the oven to 400°F and line a sheet pan.',
'Mix the turkey, breadcrumbs, and egg; roll into 18 balls.',
'Bake for 18 minutes until browned.',
'Simmer the garlic and tomatoes for 10 minutes.',
]);
});
});
} }

View File

@ -0,0 +1,303 @@
import 'dart:math' as math;
import 'package:flutter_test/flutter_test.dart';
import 'package:glp1_companion/features/medication/domain/entities/dose_entry.dart';
import 'package:glp1_companion/features/medication/domain/usecases/calculate_serum_level.dart';
import 'package:glp1_companion/features/medication/domain/value_objects/glp1_compound.dart';
/// Pharmacokinetics regression suite for the single-compartment decay model
/// `C(t) = Σ D_i · 0.5^((t − t_i)/t_half)`.
///
/// Focuses on the multi-dose / steady-state behaviour that titration
/// planning depends on:
///
/// * **Semaglutide** — `t½ = 168 h`, i.e. exactly one weekly interval, so a
/// weekly dosing ladder has a per-interval decay factor `r = 0.5` and
/// converges to `peak → 2·D`, `trough → D`.
/// * **Tirzepatide** — `t½ = 120 h`, so weekly dosing decays by
/// `r = 0.5^(168/120) = 0.5^1.4 ≈ 0.3789` per interval and converges to
/// `peak → D/(1−r) ≈ 1.61·D`, `trough → D·r/(1−r) ≈ 0.61·D` — a wider
/// peak-to-trough fluctuation than semaglutide, as observed clinically.
DoseEntry _dose({
required String compound,
required double mg,
required DateTime administeredAt,
}) =>
DoseEntry(
compoundName: compound,
doseMg: mg,
administeredAt: administeredAt,
);
/// Independent reference implementation of the decay model (sums the
/// contribution of every dose in [doses] evaluated at [at]).
double _referenceSerum({
required Iterable<DoseEntry> doses,
required DateTime at,
required double halfLifeHours,
}) {
var total = 0.0;
for (final dose in doses) {
final elapsedHours = at.difference(dose.administeredAt).inMilliseconds /
Duration.millisecondsPerHour;
if (elapsedHours < 0) continue;
total +=
dose.doseMg * math.pow(0.5, elapsedHours / halfLifeHours).toDouble();
}
return total;
}
void main() {
const calculator = CalculateSerumLevel();
/// Fixed anchor so dose spacing is exact (no clock drift in assertions).
final epoch = DateTime(2026, 1, 5, 8);
const week = Duration(hours: 168);
/// Weekly dosing ladder: [count] doses of [mg] spaced exactly one
/// semaglutide half-life apart, starting at [epoch].
List<DoseEntry> weeklyLadder({
required String compound,
required double mg,
required int count,
}) => [
for (var i = 0; i < count; i++)
_dose(
compound: compound,
mg: mg,
administeredAt: epoch.add(week * i),
),
];
group('Semaglutide weekly steady state (t½ = 168 h)', () {
test('r = 0.5: trough converges to D, peak to 2·D', () {
const doseMg = 1.0;
// Peak after the 8th weekly dose: D·(2 − 0.5^7) ≈ 1.9922 mg.
final peak = calculator.evaluate(
doses: weeklyLadder(compound: 'Semaglutide', mg: doseMg, count: 8),
at: epoch.add(week * 7),
);
expect(peak, closeTo(doseMg * (2 - math.pow(0.5, 7)), 1e-9));
// Trough just before a hypothetical 9th dose: D·(1 − 0.5^8).
final trough = calculator.evaluate(
doses: weeklyLadder(compound: 'Semaglutide', mg: doseMg, count: 8),
at: epoch.add(week * 8),
);
expect(trough, closeTo(doseMg * (1 - math.pow(0.5, 8)), 1e-9));
// Both sit within 1 % of the analytic steady-state limits.
expect(peak, closeTo(2 * doseMg, 0.01 * 2 * doseMg));
expect(trough, closeTo(doseMg, 0.01 * doseMg));
});
test('troughs rise monotonically week over week toward the plateau', () {
final troughs = <double>[];
for (var n = 1; n <= 8; n++) {
troughs.add(
calculator.evaluate(
doses: weeklyLadder(compound: 'Semaglutide', mg: 1, count: n),
at: epoch.add(week * n),
),
);
}
for (var i = 1; i < troughs.length; i++) {
expect(troughs[i], greaterThan(troughs[i - 1]),
reason: 'accumulation must keep rising before steady state');
}
expect(troughs.first, closeTo(0.5, 1e-9));
expect(troughs.last, closeTo(1, 0.01));
});
test('peak-to-trough fluctuation converges on one dose (r = 0.5)', () {
final doses = weeklyLadder(compound: 'Semaglutide', mg: 2, count: 8);
final peak = calculator.evaluate(doses: doses, at: epoch.add(week * 7));
final trough = calculator.evaluate(doses: doses, at: epoch.add(week * 8));
// At week 8 the spread is D·(1 − 0.5^8) and tightens onto the
// steady-state fluctuation of exactly one dose (D = 2 mg) thereafter.
expect(
peak - trough,
closeTo(2 * (1 - math.pow(0.5, 8)), 1e-9),
);
});
});
group('Tirzepatide weekly steady state (t½ = 120 h)', () {
// Weekly decay factor for a 120 h half-life: 0.5^(168/120) = 0.5^1.4.
final r = math.pow(0.5, 168 / 120).toDouble();
test('accumulation matches the geometric series with r = 0.5^1.4', () {
const doseMg = 2.5;
const doses = 10;
final ladder = weeklyLadder(
compound: 'Tirzepatide',
mg: doseMg,
count: doses,
);
// Peak just after the 10th dose: D·(1 − r^10)/(1 − r).
final expectedPeak = doseMg * (1 - math.pow(r, doses)) / (1 - r);
final peak =
calculator.evaluate(doses: ladder, at: epoch.add(week * (doses - 1)));
expect(peak, closeTo(expectedPeak, 1e-9));
// Trough just before the next dose: D·r·(1 − r^10)/(1 − r).
final expectedTrough = expectedPeak * r;
final trough =
calculator.evaluate(doses: ladder, at: epoch.add(week * doses));
expect(trough, closeTo(expectedTrough, 1e-9));
// Converged within 0.1 % of the analytic steady state D/(1−r).
expect(peak, closeTo(doseMg / (1 - r), 0.001 * doseMg / (1 - r)));
});
test('one missed interval decays the latest dose by exactly one half-life',
() {
final doses = weeklyLadder(compound: 'Tirzepatide', mg: 5, count: 3);
// 120 h (one t½) after the latest dose: 5 mg → 2.5 mg, plus the
// residual of the two earlier weekly doses.
final at = epoch.add(week * 2 + const Duration(hours: 120));
expect(
calculator.evaluate(doses: doses, at: at),
closeTo(
_referenceSerum(doses: doses, at: at, halfLifeHours: 120),
1e-9,
),
);
expect(calculator.evaluate(doses: doses, at: at), greaterThan(2.5));
});
test('weekly dosing fluctuates more than semaglutide (1/r > 2)', () {
final semaLadder =
weeklyLadder(compound: 'Semaglutide', mg: 1, count: 12);
final tirzeLadder =
weeklyLadder(compound: 'Tirzepatide', mg: 1, count: 12);
double ratio(List<DoseEntry> doses) {
final peak = calculator.evaluate(doses: doses, at: epoch.add(week * 11));
final trough = calculator.evaluate(doses: doses, at: epoch.add(week * 12));
return peak / trough;
}
expect(ratio(semaLadder), closeTo(2, 1e-6));
expect(ratio(tirzeLadder), closeTo(1 / r, 1e-6));
expect(ratio(tirzeLadder), greaterThan(ratio(semaLadder)));
});
});
group('multi-dose superposition', () {
test('mixed-age semaglutide doses sum independently', () {
final doses = [
_dose(
compound: 'Semaglutide',
mg: 2,
administeredAt: epoch.subtract(const Duration(hours: 336)),
),
_dose(
compound: 'Semaglutide',
mg: 1,
administeredAt: epoch.subtract(const Duration(hours: 168)),
),
_dose(compound: 'Semaglutide', mg: 0.5, administeredAt: epoch),
];
// 2·0.5² + 1·0.5 + 0.5 = 1.5 mg.
expect(calculator.evaluate(doses: doses, at: epoch), closeTo(1.5, 1e-9));
});
test('mixed-age tirzepatide doses sum independently', () {
final doses = [
_dose(
compound: 'Tirzepatide',
mg: 5,
administeredAt: epoch.subtract(const Duration(hours: 240)),
),
_dose(
compound: 'Tirzepatide',
mg: 2.5,
administeredAt: epoch.subtract(const Duration(hours: 120)),
),
_dose(compound: 'Tirzepatide', mg: 2.5, administeredAt: epoch),
];
// 5·0.5² + 2.5·0.5 + 2.5 = 5.0 mg.
expect(calculator.evaluate(doses: doses, at: epoch), closeTo(5, 1e-9));
});
test('titration ladder: 0.25 mg ×4 weeks then 0.5 mg ×4 (semaglutide)',
() {
final doses = <DoseEntry>[
for (var i = 0; i < 4; i++)
_dose(
compound: 'Ozempic', // Brand alias must resolve to t½ = 168 h.
mg: 0.25,
administeredAt: epoch.add(week * i),
),
for (var i = 4; i < 8; i++)
_dose(
compound: 'Ozempic',
mg: 0.5,
administeredAt: epoch.add(week * i),
),
];
// Peak just after the first full 0.5 mg dose (week 5):
// 0.5 + 0.25·(0.5 + 0.25 + 0.125 + 0.0625) = 0.734375 mg.
final peakAt = epoch.add(week * 4);
final expectedPeak = _referenceSerum(
doses: doses,
at: peakAt,
halfLifeHours: Glp1Compound.semaglutide.halfLifeHours,
);
expect(expectedPeak, closeTo(0.734375, 1e-9));
expect(
calculator.evaluate(doses: doses, at: peakAt),
closeTo(expectedPeak, 1e-9),
);
// The trough before the next dose keeps accumulating (~0.4834 mg),
// well above the old 0.25 mg-dose plateau trough.
final nextTroughAt = epoch.add(week * 8);
final trough = calculator.evaluate(doses: doses, at: nextTroughAt);
expect(
trough,
closeTo(
_referenceSerum(
doses: doses,
at: nextTroughAt,
halfLifeHours: Glp1Compound.semaglutide.halfLifeHours,
),
1e-9,
),
);
expect(trough, greaterThan(0.48));
expect(trough, lessThan(1));
});
test('longer half-life retains more of an identical history', () {
final at = epoch.add(const Duration(hours: 120));
double retained(String compound) => calculator.evaluate(
doses: [_dose(compound: compound, mg: 4, administeredAt: epoch)],
at: at,
);
// After 120 h: tirzepatide keeps 50 %, semaglutide ~61.4 %.
expect(retained('Tirzepatide'), closeTo(2, 1e-9));
expect(
retained('Semaglutide'),
closeTo(4 * math.pow(0.5, 120 / 168), 1e-9),
);
expect(retained('Semaglutide'), greaterThan(retained('Tirzepatide')));
});
test('five half-lives wash out > 96 % of a single dose', () {
final at = epoch.add(const Duration(hours: 120 * 5));
expect(
calculator.evaluate(
doses: [_dose(compound: 'Tirzepatide', mg: 10, administeredAt: epoch)],
at: at,
),
closeTo(10 * math.pow(0.5, 5), 1e-9), // 0.3125 mg = 3.125 %.
);
});
});
}

View File

@ -0,0 +1,229 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.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';
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';
import 'package:glp1_companion/features/nutrition/presentation/screens/nutrition_screen.dart';
/// In-memory [INutritionRepository] fake with auto-incrementing ids, matching
/// the drift-backed behaviour the controller relies on.
class _FakeNutritionRepository implements INutritionRepository {
final List<FoodLogEntry> _foods = [];
final List<HydrationLogEntry> _waters = [];
int _nextId = 1;
@override
Stream<List<FoodLogEntry>> watchFoodLogs({int limit = 500}) =>
Stream.value(_foods.take(limit).toList());
@override
Future<List<FoodLogEntry>> getFoodLogs({
int limit = 500,
DateTime? from,
DateTime? to,
}) async =>
_foods
.where((entry) =>
(from == null || !entry.timestamp.isBefore(from)) &&
(to == null || entry.timestamp.isBefore(to)))
.take(limit)
.toList();
@override
Future<FoodLogEntry> saveFoodLog(FoodLogEntry entry) async {
final stored = entry.copyWith(id: entry.id ?? _nextId++);
_foods.removeWhere((existing) => existing.id == stored.id);
_foods.insert(0, 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(_waters.take(limit).toList());
@override
Future<List<HydrationLogEntry>> getHydrationLogs({
int limit = 500,
DateTime? from,
DateTime? to,
}) async =>
_waters
.where((entry) =>
(from == null || !entry.timestamp.isBefore(from)) &&
(to == null || entry.timestamp.isBefore(to)))
.take(limit)
.toList();
@override
Future<HydrationLogEntry> saveHydrationLog(HydrationLogEntry entry) async {
final stored = entry.copyWith(id: entry.id ?? _nextId++);
_waters.insert(0, stored);
return stored;
}
@override
Future<void> deleteHydrationLog(int id) async {
_waters.removeWhere((entry) => entry.id == id);
}
}
/// Empty [IHealthSyncRepository] fake for the weight-tracker card.
class _EmptyHealthSyncRepository implements IHealthSyncRepository {
@override
Stream<List<BiometricEntry>> watchBiometrics({int limit = 500}) =>
Stream.value(const []);
@override
Future<List<BiometricEntry>> getBiometrics({
int limit = 500,
DateTime? from,
DateTime? to,
}) async =>
const [];
@override
Future<BiometricEntry> saveBiometric(BiometricEntry entry) async => entry;
@override
Future<void> deleteBiometric(int id) async {}
@override
Future<BiometricEntry?> getLatestBiometric() async => null;
@override
Future<void> clearSyncedRange({
required BiometricSource source,
required DateTime start,
required DateTime end,
}) async {}
}
void main() {
late _FakeNutritionRepository repository;
setUp(() => repository = _FakeNutritionRepository());
Future<void> pumpScreen(WidgetTester tester) async {
// A tall viewport keeps the protein card, hydration card, and quick-add
// buttons visible without scrolling.
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
ProviderScope(
overrides: [
nutritionRepositoryProvider.overrideWithValue(repository),
healthSyncRepositoryProvider
.overrideWithValue(_EmptyHealthSyncRepository()),
],
child: const MaterialApp(home: NutritionScreen()),
),
);
await tester.pumpAndSettle();
}
/// First [LinearProgressIndicator] is protein, second is hydration.
double proteinBar(WidgetTester tester) => tester
.widgetList<LinearProgressIndicator>(find.byType(LinearProgressIndicator))
.first
.value!;
double hydrationBar(WidgetTester tester) => tester
.widgetList<LinearProgressIndicator>(find.byType(LinearProgressIndicator))
.elementAt(1)
.value!;
testWidgets('progress bars react to a new protein log entry',
(tester) async {
await pumpScreen(tester);
expect(find.text('0.0g / 100g goal'), findsOneWidget);
expect(proteinBar(tester), 0);
final container = ProviderScope.containerOf(
tester.element(find.byType(NutritionScreen)),
);
final logged = await container
.read(nutritionControllerProvider.notifier)
.logFood(
mealType: MealType.breakfast,
description: 'Greek yogurt bowl',
proteinGrams: 40,
calories: 220,
);
expect(logged, isTrue);
await tester.pumpAndSettle();
// 40 g of a 100 g target -> the meter and total update reactively.
expect(find.text('40.0g / 100g goal'), findsOneWidget);
expect(proteinBar(tester), closeTo(0.4, 1e-9));
expect(hydrationBar(tester), 0);
});
testWidgets('quick-add water buttons advance the hydration meter',
(tester) async {
await pumpScreen(tester);
expect(find.text('0 oz / 96 oz goal'), findsOneWidget);
expect(hydrationBar(tester), 0);
await tester.tap(find.text('+8.0 oz'));
await tester.pumpAndSettle();
expect(find.text('8 oz / 96 oz goal'), findsOneWidget);
expect(hydrationBar(tester), closeTo(8 / 96, 1e-9));
await tester.tap(find.text('+16.0 oz'));
await tester.pumpAndSettle();
expect(find.text('24 oz / 96 oz goal'), findsOneWidget);
expect(hydrationBar(tester), closeTo(24 / 96, 1e-9));
});
testWidgets('both meters saturate and surface the success chips',
(tester) async {
await pumpScreen(tester);
final container = ProviderScope.containerOf(
tester.element(find.byType(NutritionScreen)),
);
final controller = container.read(nutritionControllerProvider.notifier);
await controller.logFood(
mealType: MealType.lunch,
description: 'Chicken bowl',
proteinGrams: 60,
calories: 520,
carbsGrams: 45,
fatGrams: 18,
);
await controller.logFood(
mealType: MealType.dinner,
description: 'Salmon plate',
proteinGrams: 40,
calories: 480,
);
await controller.logWater(96);
await tester.pumpAndSettle();
expect(proteinBar(tester), 1);
expect(hydrationBar(tester), 1);
// Only the protein card carries a "Goal met" chip; the hydration card
// signals completion through its saturated (teal) meter.
expect(find.text('Goal met'), findsOneWidget);
expect(find.text('100.0g / 100g goal'), findsOneWidget);
expect(find.text('96 oz / 96 oz goal'), findsOneWidget);
});
}

View File

@ -281,6 +281,150 @@ void main() {
}); });
}); });
group('CalculateShoppingDelta — unit conversions', () {
test('aggregates kg and g onto one scale, reporting in the dominant unit',
() async {
await repository.saveItem(
makeStock(name: 'beef mince', quantity: 300, unit: 'g'),
);
final metricRecipe = makeRecipe(
title: 'Bolognese',
ingredients: [
ingredient('beef mince', 1.25, 'kg', rawText: '1.25 kg beef mince'),
],
);
final gramRecipe = makeRecipe(
title: 'Tacos',
ingredients: [ingredient('beef mince', 500, 'g', rawText: '500 g beef mince')],
);
await deltaEngine(
slots: [
makeSlot(day: weekMonday, recipeId: metricRecipe.id),
makeSlot(
day: weekMonday.add(const Duration(days: 1)),
recipeId: gramRecipe.id,
),
],
recipes: [metricRecipe, gramRecipe],
);
final entry = (await repository.getShoppingList())
.singleWhere((item) => item.name == 'beef mince');
// 1.25 kg + 500 g − 300 g = 1450 g, reported in the dominant unit kg.
expect(entry.unit, 'kg');
expect(entry.requiredQuantity, closeTo(1.45, 0.001));
});
test('unifies l and ml volumes before computing the deficit', () async {
await repository.saveItem(
makeStock(name: 'vegetable stock', quantity: 250, unit: 'ml'),
);
final litreRecipe = makeRecipe(
title: 'Risotto',
ingredients: [
ingredient('vegetable stock', 1, 'l', rawText: '1 l vegetable stock'),
],
);
final mlRecipe = makeRecipe(
title: 'Soup',
ingredients: [
ingredient('vegetable stock', 500, 'ml', rawText: '500 ml vegetable stock'),
],
);
await deltaEngine(
slots: [
makeSlot(day: weekMonday, recipeId: litreRecipe.id),
makeSlot(
day: weekMonday.add(const Duration(days: 2)),
recipeId: mlRecipe.id,
),
],
recipes: [litreRecipe, mlRecipe],
);
final entry = (await repository.getShoppingList())
.singleWhere((item) => item.name == 'vegetable stock');
// 1 l + 500 ml − 250 ml = 1250 ml, reported in the dominant unit l.
expect(entry.unit, 'l');
expect(entry.requiredQuantity, closeTo(1.25, 0.001));
});
test('a mass requirement never cancels against volume stock', () async {
await repository.saveItem(
makeStock(name: 'broth', quantity: 100, unit: 'ml'),
);
final recipe = makeRecipe(
title: 'Glaze',
ingredients: [
ingredient('broth', 200, 'g', rawText: '200 g broth'),
ingredient('broth', 300, 'ml', rawText: '300 ml broth'),
],
);
await deltaEngine(
slots: [makeSlot(day: weekMonday, recipeId: recipe.id)],
recipes: [recipe],
);
final list = await repository.getShoppingList();
expect(list, hasLength(1));
final entry = list.single;
// The volume line is skipped (dimension mismatch with the mass
// requirement) and the ml stock cannot offset a gram deficit.
expect(entry.unit, 'g');
expect(entry.requiredQuantity, closeTo(200, 0.001));
});
test('unit synonyms and plurals fold onto one canonical scale', () async {
// Pantry saved with a spelled-out plural unit still offsets grams.
await repository.saveItem(
makeStock(name: 'almond flour', quantity: 200, unit: 'grams'),
);
final cupRecipe = makeRecipe(
title: 'Muffins',
ingredients: [
ingredient('almond flour', 1, 'cup', rawText: '1 cup almond flour'),
],
);
final cupsRecipe = makeRecipe(
title: 'Pancakes',
ingredients: [
ingredient('almond flour', 2, 'cups', rawText: '2 cups almond flour'),
],
);
final gramRecipe = makeRecipe(
title: 'Cookies',
ingredients: [
ingredient('almond flour', 300, 'g', rawText: '300 grams almond flour'),
],
);
await deltaEngine(
slots: [
makeSlot(day: weekMonday, recipeId: cupRecipe.id),
makeSlot(
day: weekMonday.add(const Duration(days: 1)),
recipeId: cupsRecipe.id,
),
makeSlot(
day: weekMonday.add(const Duration(days: 2)),
recipeId: gramRecipe.id,
),
],
recipes: [cupRecipe, cupsRecipe, gramRecipe],
);
final flour = (await repository.getShoppingList())
.singleWhere((item) => item.name == 'almond flour');
// 1 + 2 cups total: the 300 g mass line cannot join the volume
// requirement, and the 200 g ("grams") pantry stock cannot offset it.
expect(flour.unit, 'cup');
expect(flour.requiredQuantity, closeTo(3, 0.01));
});
});
group('ShoppingListController', () { group('ShoppingListController', () {
test('checking off with pantry transfer increments stock', () async { test('checking off with pantry transfer increments stock', () async {
await repository.saveItem( await repository.saveItem(

View File

@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<title>Weeknight Turkey Meatballs Recipe | Example Eats</title>
<meta property="og:title" content="Weeknight Turkey Meatballs" />
<meta property="og:image" content="https://example.eats/img/turkey-meatballs-1000.jpg" />
<!-- First script: site chrome (organization + breadcrumbs), no Recipe. -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"name": "Example Eats",
"url": "https://example.eats/"
},
{
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://example.eats/" },
{ "@type": "ListItem", "position": 2, "name": "Recipes", "item": "https://example.eats/recipes" }
]
}
]
}
</script>
<!-- Second script: the complex @graph-hosted Recipe payload. -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Person",
"name": "Alex Rivera"
},
{
"@type": "Recipe",
"@id": "https://example.eats/recipes/turkey-meatballs/#recipe",
"mainEntityOfPage": { "@type": "WebPage", "@id": "https://example.eats/recipes/turkey-meatballs" },
"name": "Weeknight Turkey Meatballs",
"author": { "@type": "Person", "name": "Alex Rivera" },
"datePublished": "2023-11-08",
"description": "Lean turkey meatballs with a bright tomato sauce.",
"keywords": "meatballs, turkey, high-protein",
"recipeCategory": "Dinner",
"recipeCuisine": "Italian",
"recipeYield": ["6 servings"],
"prepTime": "PT20M",
"cookTime": "PT25M",
"totalTime": "PT45M",
"nutrition": {
"@type": "NutritionInformation",
"calories": "321 calories",
"proteinContent": "29 g",
"carbohydrateContent": "18 g",
"fatContent": "14 g"
},
"image": [
"https://example.eats/img/turkey-meatballs-1000.jpg",
{
"@type": "ImageObject",
"url": "https://example.eats/img/turkey-meatballs-500.jpg"
}
],
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": 4.7,
"ratingCount": 213
},
"video": {
"@type": "VideoObject",
"name": "How to make turkey meatballs",
"contentUrl": "https://example.eats/video/meatballs.mp4"
},
"recipeIngredient": [
"1.25 lb ground turkey",
"1/2 cup breadcrumbs",
"1 large egg",
"28 oz crushed tomatoes",
"2 cloves garlic, minced"
],
"recipeInstructions": [
{
"@type": "HowToStep",
"text": "Heat the oven to 400°F and line a sheet pan."
},
{
"@type": "HowToSection",
"name": "Meatballs",
"itemListElement": [
{
"@type": "HowToStep",
"text": "Mix the turkey, breadcrumbs, and egg; roll into 18 balls."
},
{
"@type": "HowToStep",
"text": "Bake for 18 minutes until browned."
}
]
},
{
"@type": "HowToSection",
"name": "Sauce",
"itemListElement": [
{
"@type": "HowToStep",
"name": "Simmer the garlic and tomatoes for 10 minutes."
}
]
}
]
}
]
}
</script>
</head>
<body>
<article itemscope itemtype="https://schema.org/Recipe">
<h1 itemprop="name">Weeknight Turkey Meatballs</h1>
</article>
</body>
</html>

View File

@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en-GB">
<head>
<meta charset="utf-8" />
<title>Classic chicken curry | BBC Good Food</title>
<meta name="description" content="A simple, healthy chicken curry for two." />
<meta property="og:type" content="recipe" />
<meta property="og:title" content="Classic chicken curry" />
<meta property="og:image" content="https://img.bbcgoodfood.com/sites/default/files/styles/hero/public/curry.jpg" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Recipe",
"name": "Classic chicken curry",
"description": "A simple, healthy curry you can make on a weeknight.",
"author": { "@type": "Person", "name": "Good Food team" },
"datePublished": "2014-04-02",
"keywords": "curry, chicken, healthy, quick",
"recipeCategory": "Dinner",
"recipeCuisine": "Indian",
"image": {
"@type": "ImageObject",
"url": "https://img.bbcgoodfood.com/sites/default/files/styles/hero/public/curry.jpg"
},
"prepTime": "PT15M",
"cookTime": "PT35M",
"totalTime": "PT50M",
"recipeYield": "4",
"nutrition": {
"@type": "NutritionInformation",
"calories": "387 calories",
"fatContent": "14 g",
"saturatedFatContent": "3 g",
"carbohydrateContent": "20 g",
"sugarContent": "8 g",
"proteinContent": "34 g",
"saltContent": "1.2 g"
},
"recipeIngredient": [
"2 chicken breasts, diced",
"1 onion, sliced",
"3 tbsp curry paste",
"100 g yogurt",
"1 tsp garam masala"
],
"recipeInstructions": [
{ "@type": "HowToStep", "text": "Fry the onion until golden." },
{ "@type": "HowToStep", "text": "Stir in the curry paste, then add the chicken." },
{ "@type": "HowToStep", "text": "Simmer for 20 minutes and finish with yogurt." }
]
}
</script>
</head>
<body>
<article>
<h1>Classic chicken curry</h1>
<p>A simple, healthy curry you can make on a weeknight.</p>
</article>
</body>
</html>

View File

@ -0,0 +1,205 @@
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:glp1_companion/app/router/app_router.dart';
import 'package:glp1_companion/core/database/app_database.dart';
import 'package:glp1_companion/core/platform/notification_service.dart';
import 'package:glp1_companion/features/auth/presentation/controllers/dev_auth_bypass.dart';
import 'package:glp1_companion/features/auth/presentation/screens/login_screen.dart';
import 'package:glp1_companion/features/medication/presentation/controllers/medication_controller.dart';
import 'package:glp1_companion/main.dart';
/// Shell-level integration tests: the adaptive scaffold must route between
/// every core destination — `/medication`, `/nutrition`, `/meal-planner`,
/// `/pantry` — under both an authenticated session and the debug dev bypass,
/// on desktop (navigation rail) and mobile (bottom navigation bar) layouts.
///
/// Supabase is never booted: the auth guard reads `isAuthenticatedProvider`
/// (overridden per scenario) and the drift database is an in-memory
/// stand-in, so the whole app graph runs offline and deterministically.
/// No-op reminders: `flutter_local_notifications` has no test host.
class _NoopNotificationService implements INotificationService {
@override
Future<void> initialize() async {}
@override
Future<bool> requestPermissions() async => false;
@override
Future<bool> scheduleDoseReminder({
required DateTime scheduledAt,
required String title,
required String body,
int id = kDoseReminderNotificationId,
String? payload,
}) async =>
false;
@override
Future<void> cancelReminder(int id) async {}
@override
Future<void> cancelAllReminders() async {}
}
void main() {
late AppDatabase database;
setUp(() {
database = AppDatabase.forTesting(NativeDatabase.memory());
});
tearDown(() => database.close());
/// Core overrides backing every scenario with offline doubles.
List<Override> baseOverrides() => [
appDatabaseProvider.overrideWithValue(database),
notificationServiceProvider
.overrideWithValue(_NoopNotificationService()),
];
Future<void> pumpApp(
WidgetTester tester, {
Size viewport = const Size(1280, 1400),
List<Override> extraOverrides = const [],
}) async {
tester.view.physicalSize = viewport;
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ProviderScope(
overrides: [...baseOverrides(), ...extraOverrides],
child: const GLP1CompanionApp(),
),
);
await tester.pumpAndSettle();
}
/// Unmounts the tree and flushes the zero-duration timers drift schedules
/// when its query streams are cancelled during container disposal, which
/// would otherwise trip the test framework's pending-timers invariant.
Future<void> unmountAndFlushTimers(WidgetTester tester) async {
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 1));
}
/// Taps the rail destination [railLabel] and asserts the shell switched to
/// the screen titled [title] with [index] selected.
Future<void> expectRailHop(
WidgetTester tester, {
required String railLabel,
required int index,
required String title,
}) async {
await tester.tap(find.text(railLabel));
await tester.pumpAndSettle();
expect(find.text(title), findsOneWidget);
expect(
tester.widget<NavigationRail>(find.byType(NavigationRail)).selectedIndex,
index,
);
}
Future<void> verifyAllRailRoutes(WidgetTester tester) async {
await expectRailHop(
tester,
railLabel: 'Nutrition',
index: 1,
title: 'Nutrition & Hydration',
);
await expectRailHop(
tester,
railLabel: 'Meal Plan',
index: 2,
title: 'Meal Planner & Import',
);
await expectRailHop(
tester,
railLabel: 'Pantry',
index: 3,
title: 'Pantry & Shopping Delta',
);
await expectRailHop(
tester,
railLabel: 'Medication',
index: 0,
title: 'Medication Tracker',
);
}
testWidgets(
'authenticated sessions start on /medication and the rail switches '
'every core route', (tester) async {
await pumpApp(
tester,
extraOverrides: [isAuthenticatedProvider.overrideWithValue(true)],
);
// Guard admits the session without visiting /login.
expect(find.byType(LoginScreen), findsNothing);
expect(find.text('Development mode - not signed in'), findsNothing);
expect(find.byType(NavigationRail), findsOneWidget);
expect(find.text('Medication Tracker'), findsOneWidget);
await verifyAllRailRoutes(tester);
await unmountAndFlushTimers(tester);
});
testWidgets('dev-bypass sessions reach every route and wear the dev banner',
(tester) async {
await pumpApp(
tester,
extraOverrides: [devAuthBypassProvider.overrideWith((ref) => true)],
);
expect(find.byType(LoginScreen), findsNothing);
expect(find.text('Development mode - not signed in'), findsOneWidget);
await verifyAllRailRoutes(tester);
// The banner persists across route switches.
expect(find.text('Development mode - not signed in'), findsOneWidget);
await unmountAndFlushTimers(tester);
});
testWidgets('mobile widths use the bottom navigation bar for every route',
(tester) async {
await pumpApp(
tester,
viewport: const Size(390, 2400),
extraOverrides: [devAuthBypassProvider.overrideWith((ref) => true)],
);
expect(find.byType(NavigationBar), findsOneWidget);
expect(find.byType(NavigationRail), findsNothing);
Future<void> tapBar(String label, int index, String title) async {
await tester.tap(find.text(label));
await tester.pumpAndSettle();
expect(find.text(title), findsOneWidget);
expect(
tester.widget<NavigationBar>(find.byType(NavigationBar)).selectedIndex,
index,
);
}
await tapBar('Food', 1, 'Nutrition & Hydration');
await tapBar('Plan', 2, 'Meal Planner & Import');
await tapBar('Pantry', 3, 'Pantry & Shopping Delta');
await tapBar('Meds', 0, 'Medication Tracker');
await unmountAndFlushTimers(tester);
});
testWidgets('unauthenticated sessions without bypass are bounced to /login',
(tester) async {
await pumpApp(tester);
expect(find.byType(LoginScreen), findsOneWidget);
expect(find.byType(NavigationRail), findsNothing);
await unmountAndFlushTimers(tester);
});
}