diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2aae7a6 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..fd91233 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 3ac6408..80971b1 100644 --- a/.gitignore +++ b/.gitignore @@ -36,7 +36,6 @@ migrate_working_dir/ /build/ # Web specific -lib/generated_plugin_registrant.dart # Symbol processing related undecorated_symbol_map.json @@ -87,4 +86,12 @@ macos/Flutter/ephemeral/ *.jks *.keystore *.key -*.p12 \ No newline at end of file +*.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 diff --git a/BUILD_STATE.md b/BUILD_STATE.md index df644b3..b9e6336 100644 --- a/BUILD_STATE.md +++ b/BUILD_STATE.md @@ -1,8 +1,8 @@ # Build Execution Ledger ## Active Target -- **Current Milestone:** Phase 5: Pantry Delta Engine & Auto-Shopping Generator — COMPLETE -- **Status:** Phase 5 Complete (next: Phase 6) +- **Current Milestone:** Phase 6: Cross-Platform Hardening, Integration Tests & Forgejo CI/CD — COMPLETE +- **Status:** Phase 6 Complete (release-ready: all phases 1–6 done) ## Phase Checklist - [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 4: Recipe Web Scraper Engine & Meal Planning Calendar - [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) - [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: " 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. -## Active Verification Gates -- [x] `flutter pub run build_runner build --delete-conflicting-outputs` -- [x] `flutter analyze` (Zero issues — `No issues found!`) -- [x] `flutter test` (99/99 passing) +## Phase 6 Deliverables (2026-10-16) +- [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] 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] 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) \ No newline at end of file diff --git a/README.md b/README.md index d923406..252dc3f 100644 --- a/README.md +++ b/README.md @@ -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) -- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) +## Architecture + +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 +/ +├── 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: + +## 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. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index edd5269..5432ea4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,13 @@ re-scheduling of pending injection reminders and notification display. --> + + + + + _publicPaths = {'/login'}; final _rootNavigatorKey = GlobalKey(); final _shellNavigatorKey = GlobalKey(); +/// 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( + (ref) => isSupabaseAvailable && + Supabase.instance.client.auth.currentSession != null, +); + /// Application router with a reactive authentication guard and dev bypass hook. final routerProvider = Provider((ref) { final authRefreshNotifier = ValueNotifier(0); @@ -81,8 +92,7 @@ String? _authGuard(BuildContext context, GoRouterState state, Ref ref) { return null; } - final isAuthenticated = isSupabaseAvailable && - Supabase.instance.client.auth.currentSession != null; + final isAuthenticated = ref.read(isAuthenticatedProvider); if (!isAuthenticated && !_publicPaths.contains(location)) { return '/login'; diff --git a/lib/core/platform/health_gateway.dart b/lib/core/platform/health_gateway.dart index b7679e7..dac4171 100644 --- a/lib/core/platform/health_gateway.dart +++ b/lib/core/platform/health_gateway.dart @@ -36,14 +36,37 @@ class HealthMetric { 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> readWeightRange({ + required DateTime start, + required DateTime end, + }); + + /// Active energy burned (kcal) samples for `[start, end)`, newest first. + Future> readEnergyRange({ + required DateTime start, + required DateTime end, + }); +} + /// 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). +/// wraps the `health` package (Health Connect / HealthKit) behind +/// `kIsWeb` / `defaultTargetPlatform` guards. /// * **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. +/// a manual-entry mode that never touches platform channels; reads are +/// served from the drift-backed cache through an injected +/// [ILocalBiometricReader]. 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. @@ -84,4 +107,8 @@ abstract class IHealthGateway { /// Platform-appropriate factory: native `health`-backed gateway on /// 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); diff --git a/lib/core/platform/health_gateway_fallback.dart b/lib/core/platform/health_gateway_fallback.dart index 024d1e7..0e0822d 100644 --- a/lib/core/platform/health_gateway_fallback.dart +++ b/lib/core/platform/health_gateway_fallback.dart @@ -1,18 +1,38 @@ +import 'package:flutter/foundation.dart'; + import '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 -/// rule 3: desktop/web get read-only manual entry until cloud OAuth partners -/// land in a later phase). +/// Graceful-degradation gateway for Web, Windows, macOS, and Linux +/// (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. +/// never crash: permissions are refused, writes are rejected, and reads are +/// served from the injected [ILocalBiometricReader] (the encrypted drift +/// cache of locally logged biometrics) — or from nothing when no reader was +/// wired. Consumers treat `supportsNativeHealth == false` as the signal to +/// open the manual-entry dialog. 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 bool get supportsNativeHealth => false; @@ -26,20 +46,41 @@ class FallbackHealthGateway implements IHealthGateway { Future> fetchWeightHistory({ required DateTime start, required DateTime end, - }) async => - const []; + }) => + _read(() => _localReader?.readWeightRange(start: start, end: end)); @override Future> fetchActiveEnergyBurned({ required DateTime start, required DateTime end, - }) async => - const []; + }) => + _read(() => _localReader?.readEnergyRange(start: start, end: end)); @override Future writeWeight({ required double weightLbs, required DateTime timestamp, - }) async => - false; + }) async { + 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> _read( + Future>? 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 []; + } + } } \ No newline at end of file diff --git a/lib/core/platform/health_gateway_mobile.dart b/lib/core/platform/health_gateway_mobile.dart index ebe961b..7ba09c1 100644 --- a/lib/core/platform/health_gateway_mobile.dart +++ b/lib/core/platform/health_gateway_mobile.dart @@ -6,23 +6,67 @@ import 'package:health/health.dart'; import '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 /// (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). +/// Guard layers (architecture rule 2/3 — zero crashes on unsupported +/// environments): +/// +/// 1. **Static:** [supportsNativeHealth] combines `kIsWeb`, +/// `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 { final Health _health = Health(); Future? _configuration; + /// Health Connect SDK availability, probed once per gateway instance. + Future? _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 _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 _iosReadTypes = [ + HealthDataType.WEIGHT, + HealthDataType.ACTIVE_ENERGY_BURNED, + ]; + @override - bool get supportsNativeHealth => - !kIsWeb && (Platform.isAndroid || Platform.isIOS); + bool get supportsNativeHealth { + 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 NativeHealthPlatform? get nativePlatform { @@ -36,19 +80,63 @@ class MobileHealthGateway implements IHealthGateway { /// before any read/write call. Future _configure() => _configuration ??= _health.configure(); + /// The read types backing this gateway on the current platform. + List get _readTypes => + Platform.isIOS ? _iosReadTypes : _androidReadTypes; + + List get _readPermissions => + List.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 _ensureHealthConnectReady() => + _healthConnectReady ??= _probeHealthConnect(); + + Future _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 Future requestPermissions() async { if (!supportsNativeHealth) return false; try { 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( - const [HealthDataType.WEIGHT, HealthDataType.ACTIVE_ENERGY_BURNED], - permissions: const [HealthDataAccess.READ, HealthDataAccess.READ], + _readTypes, + permissions: _readPermissions, ); } catch (error, stackTrace) { debugPrintStack( stackTrace: stackTrace, - label: 'health requestPermissions failed (non-fatal)', + label: 'HealthGateway: requestPermissions failed (non-fatal)', ); return false; } @@ -58,7 +146,7 @@ class MobileHealthGateway implements IHealthGateway { Future> fetchWeightHistory({ required DateTime start, required DateTime end, - }) async => + }) => _fetch( type: HealthDataType.WEIGHT, start: start, @@ -71,7 +159,7 @@ class MobileHealthGateway implements IHealthGateway { Future> fetchActiveEnergyBurned({ required DateTime start, required DateTime end, - }) async => + }) => _fetch( type: HealthDataType.ACTIVE_ENERGY_BURNED, start: start, @@ -88,6 +176,9 @@ class MobileHealthGateway implements IHealthGateway { if (!supportsNativeHealth) return false; try { await _configure(); + if (Platform.isAndroid && !await _ensureHealthConnectReady()) { + return false; + } return await _health.writeHealthData( value: weightLbs * 0.45359237, unit: HealthDataUnit.KILOGRAM, @@ -99,14 +190,15 @@ class MobileHealthGateway implements IHealthGateway { } catch (error, stackTrace) { debugPrintStack( stackTrace: stackTrace, - label: 'health writeWeight failed (non-fatal)', + label: 'HealthGateway: 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. + /// Shared query path: re-validate readiness + permission, read [type] over + /// `[start, end)`, keep only numeric values, normalize each sample via + /// [normalize], and sort newest-first. Never throws. Future> _fetch({ required HealthDataType type, required DateTime start, @@ -117,6 +209,24 @@ class MobileHealthGateway implements IHealthGateway { if (!supportsNativeHealth) return const []; try { 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( types: [type], startTime: start, @@ -141,7 +251,7 @@ class MobileHealthGateway implements IHealthGateway { // degrade to "no data" so the UI can offer manual entry instead. debugPrintStack( stackTrace: stackTrace, - label: 'health $type query failed (non-fatal)', + label: 'HealthGateway: $type query failed (non-fatal)', ); return const []; } @@ -164,4 +274,4 @@ class MobileHealthGateway implements IHealthGateway { /// Active energy normally arrives in kilocalories; normalize joules too. double _energyInKcal(double raw, HealthDataUnit unit) => unit == HealthDataUnit.JOULE ? raw / 4.184 : raw; -} \ No newline at end of file +} diff --git a/lib/core/security/crypto_manager.dart b/lib/core/security/crypto_manager.dart index 0019a43..dc29fc9 100644 --- a/lib/core/security/crypto_manager.dart +++ b/lib/core/security/crypto_manager.dart @@ -1,25 +1,101 @@ import 'dart:convert'; import 'dart:math'; + +import 'package:flutter/foundation.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 { 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}) - : _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 getOrCreateDatabaseEncryptionKey() async { - final existingKey = await _secureStorage.read(key: _databaseKeyName); + final existingKey = + await _tryOrNull(() => _secureStorage.read(key: _databaseKeyName)); if (existingKey != null && existingKey.isNotEmpty) { 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 values = List.generate(32, (i) => random.nextInt(256)); - final newKey = base64UrlEncode(values); + return base64UrlEncode(values); + } - await _secureStorage.write(key: _databaseKeyName, value: newKey); - return newKey; + /// Runs a vault operation, returning `null` (instead of throwing) on any + /// platform failure so callers can degrade gracefully. + Future _tryOrNull(Future Function() operation) async { + try { + return await operation(); + } catch (error, stackTrace) { + debugPrintStack( + stackTrace: stackTrace, + label: 'Secure vault access failed (non-fatal): $error', + ); + return null; + } } } diff --git a/lib/features/health_sync/presentation/controllers/biometric_controller.dart b/lib/features/health_sync/presentation/controllers/biometric_controller.dart index 6da1ccf..78e3807 100644 --- a/lib/features/health_sync/presentation/controllers/biometric_controller.dart +++ b/lib/features/health_sync/presentation/controllers/biometric_controller.dart @@ -18,11 +18,61 @@ final healthSyncRepositoryProvider = Provider( /// Cross-platform health boundary (HealthKit / Health Connect on mobile, /// 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. final healthGatewayProvider = Provider( - (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> 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> 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. class BiometricState { const BiometricState({ diff --git a/lib/features/nutrition/presentation/screens/nutrition_screen.dart b/lib/features/nutrition/presentation/screens/nutrition_screen.dart index a2ff83c..62a346c 100644 --- a/lib/features/nutrition/presentation/screens/nutrition_screen.dart +++ b/lib/features/nutrition/presentation/screens/nutrition_screen.dart @@ -80,12 +80,16 @@ class _ProteinCard extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - 'Daily Protein Target', - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith(fontWeight: FontWeight.bold), + // 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', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), ), if (reachedGoal) Chip( @@ -378,7 +382,12 @@ class _WeightTrackerCard extends ConsumerWidget { ], ), 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: [ FilledButton.icon( onPressed: state.isSyncing @@ -396,7 +405,6 @@ class _WeightTrackerCard extends ConsumerWidget { ), 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), diff --git a/lib/features/recipes/data/recipe_scraper.dart b/lib/features/recipes/data/recipe_scraper.dart deleted file mode 100644 index 5dcc20a..0000000 --- a/lib/features/recipes/data/recipe_scraper.dart +++ /dev/null @@ -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 ingredients; - final List instructions; - final double? proteinGrams; - - ScrapedRecipe({ - required this.title, - required this.ingredients, - required this.instructions, - this.proteinGrams, - }); -} - -class RecipeScraperService { - Future 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 && 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 json) { - return ScrapedRecipe( - title: json['name'] ?? 'Untitled Recipe', - ingredients: List.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.]'), '') ?? - '', - ), - ); - } -} diff --git a/test/features/meal_planner/recipe_scraper_test.dart b/test/features/meal_planner/recipe_scraper_test.dart index e4fa0b5..f7f9654 100644 --- a/test/features/meal_planner/recipe_scraper_test.dart +++ b/test/features/meal_planner/recipe_scraper_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.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.', + ]); + }); + }); } diff --git a/test/features/medication/serum_calculator_test.dart b/test/features/medication/serum_calculator_test.dart new file mode 100644 index 0000000..e2510f6 --- /dev/null +++ b/test/features/medication/serum_calculator_test.dart @@ -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 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 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 = []; + 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 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 = [ + 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 %. + ); + }); + }); +} \ No newline at end of file diff --git a/test/features/nutrition/nutrition_widget_test.dart b/test/features/nutrition/nutrition_widget_test.dart new file mode 100644 index 0000000..ee330ee --- /dev/null +++ b/test/features/nutrition/nutrition_widget_test.dart @@ -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 _foods = []; + final List _waters = []; + int _nextId = 1; + + @override + Stream> watchFoodLogs({int limit = 500}) => + Stream.value(_foods.take(limit).toList()); + + @override + Future> 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 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 deleteFoodLog(int id) async { + _foods.removeWhere((entry) => entry.id == id); + } + + @override + Stream> watchHydrationLogs({int limit = 500}) => + Stream.value(_waters.take(limit).toList()); + + @override + Future> 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 saveHydrationLog(HydrationLogEntry entry) async { + final stored = entry.copyWith(id: entry.id ?? _nextId++); + _waters.insert(0, stored); + return stored; + } + + @override + Future deleteHydrationLog(int id) async { + _waters.removeWhere((entry) => entry.id == id); + } +} + +/// Empty [IHealthSyncRepository] fake for the weight-tracker card. +class _EmptyHealthSyncRepository implements IHealthSyncRepository { + @override + Stream> watchBiometrics({int limit = 500}) => + Stream.value(const []); + + @override + Future> getBiometrics({ + int limit = 500, + DateTime? from, + DateTime? to, + }) async => + const []; + + @override + Future saveBiometric(BiometricEntry entry) async => entry; + + @override + Future deleteBiometric(int id) async {} + + @override + Future getLatestBiometric() async => null; + + @override + Future clearSyncedRange({ + required BiometricSource source, + required DateTime start, + required DateTime end, + }) async {} +} + +void main() { + late _FakeNutritionRepository repository; + + setUp(() => repository = _FakeNutritionRepository()); + + Future 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(find.byType(LinearProgressIndicator)) + .first + .value!; + + double hydrationBar(WidgetTester tester) => tester + .widgetList(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); + }); +} diff --git a/test/features/pantry/delta_calculator_test.dart b/test/features/pantry/delta_calculator_test.dart index 3b70c86..13fa5dd 100644 --- a/test/features/pantry/delta_calculator_test.dart +++ b/test/features/pantry/delta_calculator_test.dart @@ -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', () { test('checking off with pantry transfer increments stock', () async { await repository.saveItem( diff --git a/test/fixtures/recipes/allrecipes_style.html b/test/fixtures/recipes/allrecipes_style.html new file mode 100644 index 0000000..123b8a5 --- /dev/null +++ b/test/fixtures/recipes/allrecipes_style.html @@ -0,0 +1,123 @@ + + + + + Weeknight Turkey Meatballs Recipe | Example Eats + + + + + + + + +
+

Weeknight Turkey Meatballs

+
+ + diff --git a/test/fixtures/recipes/bbc_good_food_style.html b/test/fixtures/recipes/bbc_good_food_style.html new file mode 100644 index 0000000..eae7e72 --- /dev/null +++ b/test/fixtures/recipes/bbc_good_food_style.html @@ -0,0 +1,60 @@ + + + + + Classic chicken curry | BBC Good Food + + + + + + + +
+

Classic chicken curry

+

A simple, healthy curry you can make on a weeknight.

+
+ + diff --git a/test/integration/app_navigation_test.dart b/test/integration/app_navigation_test.dart new file mode 100644 index 0000000..29b8701 --- /dev/null +++ b/test/integration/app_navigation_test.dart @@ -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 initialize() async {} + + @override + Future requestPermissions() async => false; + + @override + Future scheduleDoseReminder({ + required DateTime scheduledAt, + required String title, + required String body, + int id = kDoseReminderNotificationId, + String? payload, + }) async => + false; + + @override + Future cancelReminder(int id) async {} + + @override + Future 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 baseOverrides() => [ + appDatabaseProvider.overrideWithValue(database), + notificationServiceProvider + .overrideWithValue(_NoopNotificationService()), + ]; + + Future pumpApp( + WidgetTester tester, { + Size viewport = const Size(1280, 1400), + List 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 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 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(find.byType(NavigationRail)).selectedIndex, + index, + ); + } + + Future 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 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(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); + }); +}