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:
parent
747d917aed
commit
51364a8f4f
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -36,7 +36,6 @@ migrate_working_dir/
|
|||
/build/
|
||||
|
||||
# Web specific
|
||||
lib/generated_plugin_registrant.dart
|
||||
|
||||
# Symbol processing related
|
||||
undecorated_symbol_map.json
|
||||
|
|
@ -88,3 +87,11 @@ macos/Flutter/ephemeral/
|
|||
*.keystore
|
||||
*.key
|
||||
*.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
|
||||
|
|
|
|||
|
|
@ -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: <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.
|
||||
|
||||
## 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)
|
||||
193
README.md
193
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
|
||||
<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.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,13 @@
|
|||
re-scheduling of pending injection reminders and notification display. -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<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
|
||||
android:label="glpcompanion"
|
||||
android:name="${applicationName}"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,17 @@ const Set<String> _publicPaths = {'/login'};
|
|||
final _rootNavigatorKey = 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.
|
||||
final routerProvider = Provider<GoRouter>((ref) {
|
||||
final authRefreshNotifier = ValueNotifier<int>(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';
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
/// 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);
|
||||
|
|
|
|||
|
|
@ -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<List<HealthMetric>> fetchWeightHistory({
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
}) async =>
|
||||
const [];
|
||||
}) =>
|
||||
_read(() => _localReader?.readWeightRange(start: start, end: end));
|
||||
|
||||
@override
|
||||
Future<List<HealthMetric>> fetchActiveEnergyBurned({
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
}) async =>
|
||||
const [];
|
||||
}) =>
|
||||
_read(() => _localReader?.readEnergyRange(start: start, end: end));
|
||||
|
||||
@override
|
||||
Future<bool> 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<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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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
|
||||
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<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
|
||||
Future<bool> 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<List<HealthMetric>> fetchWeightHistory({
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
}) async =>
|
||||
}) =>
|
||||
_fetch(
|
||||
type: HealthDataType.WEIGHT,
|
||||
start: start,
|
||||
|
|
@ -71,7 +159,7 @@ class MobileHealthGateway implements IHealthGateway {
|
|||
Future<List<HealthMetric>> 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<List<HealthMetric>> _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 [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> 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<int>.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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,11 +18,61 @@ final healthSyncRepositoryProvider = Provider<IHealthSyncRepository>(
|
|||
/// 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<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.
|
||||
class BiometricState {
|
||||
const BiometricState({
|
||||
|
|
|
|||
|
|
@ -80,13 +80,17 @@ class _ProteinCard extends StatelessWidget {
|
|||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
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',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
if (reachedGoal)
|
||||
Chip(
|
||||
label: const Text('Goal met'),
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.]'), '') ??
|
||||
'',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.',
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 %.
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue