157 lines
42 KiB
Markdown
157 lines
42 KiB
Markdown
# Build Execution Ledger
|
||
|
||
## Active Target
|
||
- **Current Milestone:** Phase 7: Navigation & Domain Expansion (Exercise, Recipe Vault, Settings) — COMPLETE
|
||
- **Status:** Phase 7 Complete (release-ready: all phases 1–7 done)
|
||
- **Latest Addendum:** Phase 3B retrofit — Platform Health Integrations & HealthGateway end-to-end (2026-10-17)
|
||
|
||
## Phase Checklist
|
||
- [x] Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB)
|
||
- [x] Phase 2: Medication Domain, Injection Rotation, Serum Modeling
|
||
- [x] Phase 3: Nutrition, Protein Target Tracking, HealthGateway Sync
|
||
- [x] Phase 3B: Platform Health Integrations & HealthGateway (architecture §4.7 contract, Settings control surface, native entitlements)
|
||
- [x] Phase 4: Recipe Web Scraper Engine & Meal Planning Calendar
|
||
- [x] Phase 5: Pantry Delta Engine & Auto-Shopping Generator
|
||
- [x] Phase 6: Hardening, Native Desktop/Web Polish, CI/CD
|
||
- [x] Phase 7: Navigation & Domain Expansion (Exercise, Recipe Vault, Settings)
|
||
|
||
## Phase 2 Deliverables (2026-10-16)
|
||
- [x] Domain: `DoseEntry` entity, 12-quadrant `InjectionSite` rotation model, `Glp1Compound` value object (Sema 168h / Tirze 120h / Lira 13h), `IMedicationRepository` contract.
|
||
- [x] Domain: `CalculateSerumLevel` use case implementing `C(t) = Σ D_i · 0.5^((t − t_i)/t_half)`.
|
||
- [x] Data: injection-history CRUD on `AppDatabase` + drift-backed `MedicationRepository` (record ↔ entity mapping).
|
||
- [x] Presentation: `MedicationController` (Riverpod StateNotifier) with serum estimation, least-recently-used site rotation, and reminder scheduling; refactored `MedicationScreen` (serum card, rotation suggestion, interactive log dialog, history with delete).
|
||
- [x] Platform: `INotificationService` / `PlatformNotificationService` (flutter_local_notifications 17.x; Android/iOS/macOS scheduled reminders, graceful no-op on Web/Windows/Linux); Android boot-receiver manifest entries; `timezone` dependency added.
|
||
- [x] Regression fix: `SupabaseAuthService` now resolves its client lazily (baseline widget test no longer throws when Supabase is uninitialized).
|
||
- [x] Tests: 33 new unit/widget-fake tests (PK calculator, rotation policy, repository w/ in-memory drift, controller); baseline smoke test green.
|
||
|
||
## Post-Phase-2 Additions
|
||
- [x] Development auth bypass (2026-10-16): debug-only "Continue Without Signing In" button on the login screen (`kDebugMode`-gated, in-memory `devAuthBypassProvider`) lets reviewers enter the app without a Supabase session; the router guard honors the flag and the shell shows an orange "Development mode - not signed in" banner while active. Covered by `test/features/auth/dev_auth_bypass_test.dart` (3 tests).
|
||
|
||
## Phase 3 Deliverables (2026-10-16)
|
||
- [x] Domain: `FoodLogEntry` (+ `MealType` enum), `HydrationLogEntry`, `BiometricEntry` (+ `BiometricSource` enum), `INutritionRepository`, `IHealthSyncRepository` — all pure Dart.
|
||
- [x] Data: `AppDatabase` schema v2 (+ migration for v1 installs) with `FoodLogsTable`, `HydrationLogsTable`, `BiometricLogsTable`, range-filtered watch/get/CRUD helpers, `clearSyncedRange` for idempotent re-syncs; drift-backed `NutritionRepository` and `HealthSyncRepository` (record ↔ entity mapping).
|
||
- [x] Platform: `HealthGateway` rewrite — `IHealthGateway` + conditional-import factory; mobile impl (`health_gateway_mobile.dart`) wraps `package:health` for `WEIGHT` (kg→lb normalization) and `ACTIVE_ENERGY_BURNED` (joule→kcal) with double-guarded try/catch; fallback impl (`health_gateway_fallback.dart`) for Web/Windows/macOS never touches platform channels (reads served from the drift cache, writes via manual entry).
|
||
- [x] Presentation: `NutritionController` (Riverpod `AsyncNotifier`; today's logs, 100 g protein / 96 oz water targets, meal grouping, quick-add water) and `BiometricController` (weight history, 7/30-day deltas, manual entry, native sync with per-day active-energy aggregation); `LogFoodDialog` and `LogWeightDialog`.
|
||
- [x] Presentation: `NutritionScreen` upgraded — interactive protein/hydration progress meters, +8/+16/+24 oz quick-add buttons, meal-grouped food diary with delete, weight tracker card (latest weight, 7/30-day trend chips, latest active burn) whose "Log Weight / Sync" button runs `HealthGateway` sync on Android/iOS and opens the manual dialog on Windows/Web; status snackbars via `ref.listen`.
|
||
- [x] Tests: 23 new tests — nutrition repository (in-memory drift: CRUD, day-range filter, watch, `MealType` round-trip), nutrition controller (targets, grouping, validation, quick-add, delete, failure retention), biometric controller (manual entry, deltas, fallback short-circuit, permission denial, weight+energy sync idempotency).
|
||
- [x] Regression fix: none required — existing 37 tests untouched and green.
|
||
|
||
## Phase 4 Deliverables (2026-10-16)
|
||
- [x] Domain: `Recipe` (+ `MacroProfile`), `RecipeIngredient` (quantity/unit/item parsing: decimals, ASCII + unicode + mixed fractions, known-unit vocabulary), `MealPlanSlot` (reuses nutrition `MealType`), `IRecipeRepository`, `IMealPlanRepository` — all pure Dart; `core/utils/uuid.dart` v4 generator (no new dependency).
|
||
- [x] Domain: `ScrapeRecipeFromUrl` use case — `package:http` download (injectable `RecipeHtmlFetcher` for tests), `package:html` DOM parse, JSON-LD `@graph`/root/array/`@type`-list navigation, `HowToStep`/`HowToSection` instruction flattening, ISO-8601 `prepTime`/`cookTime`, `recipeYield`, regex nutrition parsing (`calories`, `proteinContent`, `carbohydrateContent`, `fatContent`), image list/object unwrap, OpenGraph fallback (`og:title`/`og:image`/`og:description`), typed `RecipeScrapeException`.
|
||
- [x] Data: `AppDatabase` schema v3 (migration for v1/v2 installs) with `RecipesTable` (UUID PK, JSON ingredient/instruction columns, macros, `createdAt`) and `MealPlanSlotsTable` (UUID PK, plannedDate, mealType, nullable `recipeId` FK with `ON DELETE SET NULL`, customNotes, isCompleted); drift-backed `DriftRecipeRepository` (URL-deduplicated `importRecipe`, `createdAt` preserved on re-import) and `DriftMealPlanRepository` (half-open `[from, to)` window, upsert-by-id).
|
||
- [x] Presentation: `RecipeImportController` (Riverpod `Notifier`; URL submit → loading → preview modal state, live catalog watch stream, confirm-save/dismiss/delete) and `MealPlanCalendarController` (Monday–Sunday window, next/previous/today navigation, slot assign/upsert/clear, prepared toggle, per-day protein rollup against the nutrition 100 g target, deleted-recipe slot normalization, stale-window refresh guard).
|
||
- [x] Presentation: `MealPlannerScreen` refactor — URL import bar with spinner, `RecipePreviewDialog` (image, meta chips, macro tiles, ingredients, steps, save with error surface), `SavedRecipesTray` (draggable cards with thumbnail + protein badge + quick-assign), `WeeklyCalendarView` (≥900 dp: 7-column grid; below: tabbed pager) with `DragTarget` meal slots, prepared checkboxes, clear menu, per-day protein indicator (progress bar vs. target), `AssignSlotDialog` (saved-recipe dropdown or custom description), week navigation header.
|
||
- [x] Tests: 25 new tests — `recipe_scraper_test.dart` (JSON-LD `@graph`/root/array/list-`@type` extraction, macro/ingredient/instruction/duration/yield parsing, malformed-JSON fallthrough, OpenGraph fallback, `execute()` URL validation & error mapping) and `meal_plan_repository_test.dart` (`startOfWeek`, recipe CRUD/URL-dedup/watch, slot window filter/upsert/delete, calendar rollup/assignment/navigation/custom entries/prepared toggles/recipe-deletion normalization, import-controller empty-URL guard).
|
||
- [x] Regression fix: HowToSection flattening now recurses into `itemListElement` before using the section `name` (parser ordering bug caught by the new test).
|
||
|
||
## Post-Phase-4 Additions
|
||
- [x] Desktop resize overflow fix (2026-10-16): the hatched yellow/black stripes (RenderFlex overflow) shown when shrinking the Windows window are gone — wide-mode day columns now scroll vertically (matching the compact pager), and a `LayoutBuilder` in `MealPlannerScreen` collapses the page into a single scrollable column (with a fixed-height calendar) whenever the body is shorter than ~520 dp. Covered by `test/features/meal_planner/meal_planner_screen_test.dart` (4 widget tests pumping the screen at short-compact, short-wide, tall, and extremely-short window sizes; overflow errors fail widget tests, so green proves no stripes).
|
||
|
||
## Phase 5 Deliverables (2026-10-16)
|
||
- [x] Domain: `PantryItem` (+ `PantryCategory` aisle enum with `infer()` keyword heuristic, `ExpirationStatus` warning buckets), `ShoppingListItem`, `IPantryRepository`, and two pure-Dart value objects — `ingredient_name.dart` (lowercase, paren-stripped, plural-folded canonical keys: `eggs` → `egg`) and `measurement_units.dart` (mass/volume/count dimensions with lb↔oz↔g↔kg and ml/l/tsp/tbsp/cup conversion).
|
||
- [x] Domain: `CalculateShoppingDelta` use case — aggregates the active 7-day `MealPlanSlot` window's `Recipe` ingredient manifests by canonical name, applies `deficit = totalRequired − currentPantryQuantity` in dimension base units (dominant recipe unit wins display, 2-decimal rounding), upserts `isAutoGenerated` rows with `sourceRecipeTitles` (stable ids, checked-state kept only while the requirement is unchanged), clears stale auto rows (satisfied by stock or superseded by a manual row), and never modifies or removes manual rows.
|
||
- [x] Data: `AppDatabase` schema v4 (migration for v1–v3 installs) with `PantryItemsTable` (UUID PK, canonical name, quantity/unit, aisle key, nullable `expirationDate`, `lastUpdated`) and `ShoppingListItemsTable` (UUID PK, required quantity/unit, purchased/auto flags, JSON `sourceNotes` for recipe titles); drift-backed `DriftPantryRepository` (watch/get/upsert/delete for both lists, enum/JSON mapping).
|
||
- [x] Presentation: `PantryInventoryController` (live watch stream, quick ± stock steps clamped at zero, aisle-grouping vs. expiration-sort modes) and `ShoppingListController` (manual add, All/To Buy/Checked filter, check-off with "Add to Pantry?" prompt performing a unit-converting stock merge or aisle-inferred row creation, `syncWithMealPlan` reading the planner's Monday–Sunday window + recipe catalog).
|
||
- [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.
|
||
|
||
## 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.
|
||
|
||
## Phase 7 Deliverables (2026-10-16)
|
||
- [x] Domain: `MuscleGroup` enum + `ExerciseEntry` entity (pure Dart), `IExerciseRepository` contract, session-scoped `InMemoryExerciseRepository` (drift table deferred to a follow-up migration).
|
||
- [x] Presentation (exercise): `ExerciseController` (weekly distinct-session rollup vs. the recommended 3 sessions/week lean-mass target) + `ExerciseScreen` with weekly strength-frequency card, day-grouped working-set log (swipe-to-delete), and the `LogExerciseDialog` (muscle group / name / sets / reps / weight).
|
||
- [x] Presentation (Recipe Vault): new `features/recipes` screen — pinned URL import bar wired to `RecipeImportController`, search field + macro `ChoiceChip` filters ("Protein > 30g", "Protein > 20g", "Under 500 kcal", All), responsive `GridView.builder` (`SliverGridDelegateWithMaxCrossAxisExtent`) with chunked infinite scroll (24-card pages, 6-card prefetch margin), details bottom sheet, delete confirmation; reuses the meal-planner recipe domain (no duplicate bounded context).
|
||
- [x] Refactor (meal planner): `MealPlannerScreen` stripped to week navigation + 7-day calendar + daily protein rollups; inline import bar and `SavedRecipesTray` removed (moved to Recipe Vault); `AssignSlotDialog`/drag-drop replaced by the searchable `AssignRecipeSheet` modal bottom sheet (Recipe Vault search + custom-entry fallback); empty slots render a "+ Assign Recipe" affordance.
|
||
- [x] Presentation (Settings): `SettingsScreen` — Identity (email, Supabase status badge, sign-out), Targets (daily protein g / water oz inputs feeding `NutritionController.updateTargets`), Security & Storage (SQLCipher AES-256 badge with web-degradation note), Integrations (Apple Health / Health Connect / HealthGateway toggles via `IntegrationsController`, native permission round-trips through `IHealthGateway`).
|
||
- [x] Shell: `app_router.dart` registers `/medication`, `/nutrition`, `/exercise`, `/meal-planner`, `/recipes`, `/pantry`, `/settings`; `AdaptiveHomeScaffold` desktop rail expanded to the 7 specified destinations (`medication_liquid`, `restaurant`, `fitness_center`, `calendar_month`, `menu_book`, `inventory_2`, `settings`) with `NavigationRailLabelType.all` inside a height-constrained scroll view (no compact-window overflow); mobile keeps 4 core tabs + a "More" tab opening the secondary-destinations drawer (Exercise, Recipes, Settings).
|
||
- [x] Tests: `app_navigation_test.dart` extended to all 7 rail routes + mobile "More" drawer flow (7 tests total); `meal_planner_screen_test.dart` rewritten for the calendar-only layout including the assign-sheet opening; `SettingsScreen` badge rows use `Flexible` + ellipsis so narrow widths never overflow.
|
||
|
||
## Post-Phase-7 Additions
|
||
- [x] Recipe import hardening (2026-10-16): in-browser URL imports failed with "could not download" — diagnosed against the live allrecipes recipe: the native fetch succeeds (HTTP 200) and the parser extracts the recipe correctly, so the failure is the **web CORS policy** (recipe sites never send `Access-Control-Allow-Origin`, so Chrome aborts before a status code exists); additionally, requests without a browser-like `User-Agent` draw HTTP 403 from anti-bot gates. Fixes in `ScrapeRecipeFromUrl`: (1) browser-like `Accept`/`Accept-Language`/`User-Agent` headers on native (UA stripped on web — forbidden header); (2) optional `--dart-define=RECIPE_PROXY_URL=<url>` that routes downloads through a server-side proxy as `<proxy>?url=<encoded>`; (3) actionable CORS guidance in the web error message when no proxy is configured. Shipped `supabase/functions/recipe-proxy/` — a deployable Deno Edge Function relay (http(s)-only, SSRF host guard, 2 MiB cap, 15 s timeout, CORS-enabled) — deploy with `supabase functions deploy recipe-proxy` and pass its URL via the define. Tests: 7 new cases in `recipe_scraper_test.dart` (header maps, proxy wrapping/encoding, error propagation) — 22/22 scraper tests green.
|
||
- [x] Web drift assets fix (2026-10-16): every drift operation failed at runtime on `flutter run -d chrome` (`watchDoseEntries failed`, `meal plan refresh failed`, `recipe catalog watch failed`, …) because `web/` was missing the WASM database artifacts `connection/web.dart` documents as required. Added `web/sqlite3.wasm` (740 KB, downloaded from the `sqlite3-3.7.1` GitHub release — matches the locked `sqlite3` version; drift 2.35.x changelog mandates a 3.x wasm) and `web/drift_worker.js` (copied from the locked `drift-2.35.2` package). Both files are not gitignored, so they ship with the repo and the CI `flutter build web` step. Verified: `flutter build web --no-tree-shake-icons` packages both into `build/web/`. NOTE: bump both files when upgrading `drift`/`sqlite3` (worker JS must match the Dart package version exactly; wasm must be ≥ 3.x).
|
||
- [x] Android toolchain fixes (2026-10-16): `flutter run` on an emulator failed on three pre-existing gaps (Android builds had never been exercised — Phase 6 gates ran on Windows/Web): (1) `flutter_local_notifications` requires core library desugaring → enabled `isCoreLibraryDesugaringEnabled` + `com.android.tools:desugar_jdk_libs:2.1.4` in `android/app/build.gradle.kts`; (2) Kotlin incremental compilation aborts when the pub cache lives on a different drive than the project (C: vs D:, "this and base files have different roots") → `kotlin.incremental=false` in `android/gradle.properties`; (3) the pinned `health` plugin declares minSdk 26 vs. Flutter's default 24 → `minSdk = maxOf(flutter.minSdkVersion, 26)` (Health Connect only ships on Android 8.0+). Verified: `flutter build apk --debug` → `✓ Built build\app\outputs\flutter-apk\app-debug.apk`.
|
||
- [x] Mobile overflow fix (2026-10-16): the compound dropdown in `LogInjectionDialog` painted yellow/black stripes on phone widths — `DropdownButtonFormField`'s selected-item Row cannot ellipsize long labels ("Tirzepatide (Mounjaro / Zepbound)") without `isExpanded: true`. Fixed with `isExpanded: true` + `maxLines: 1`/`TextOverflow.ellipsis` on the menu items; regression tests in `test/features/medication/log_injection_dialog_test.dart` pump the dialog at 320 dp (both long labels) and 390 dp asserting no RenderFlex overflow (128/128 tests passing).
|
||
|
||
## Post-Phase-7 Additions
|
||
- [x] App icon branding (2026-10-16): replaced all stock Flutter icons with the "cg" leaf monogram brand mark. Master asset `assets/icon/app_icon.jpg` (2048×2048); `assets/icon/generate_icons.py` (Python/Pillow, Lanczos) regenerates every platform size idempotently: web (`favicon.png` 32, `icons/Icon-{192,512}.png` with feathered circular alpha, `icons/Icon-maskable-{192,512}.png` at 80% safe-zone over brand sage `#D4E4C7`), Android (legacy `mipmap-*/ic_launcher.png` 48–192 px + new adaptive icons: `mipmap-anydpi-v26/ic_launcher{,_round}.xml`, `drawable/ic_launcher_background.xml` sage vector, `drawable-*/ic_launcher_foreground.png` 66/108 dp safe circle), iOS (all 15 `AppIcon.appiconset` PNGs — opaque RGB, no alpha, per App Store validation), macOS (`app_icon_{16..1024}.png`), Windows (`app_icon.ico` multi-res 16–256 px). Web polish: `manifest.json` name/description/theme (`#375A53` teal, `#D4E4C7` sage) and `index.html` title/description/`theme-color` updated from Flutter placeholders. Verified: PIL dimension/mode/alpha assertions on every output + XML well-formedness + ICO frame enumeration; `flutter build web --no-tree-shake-icons` and `flutter build apk --debug` package the new assets.
|
||
|
||
## Phase 3B Deliverables (2026-10-17, architecture §4.7 retrofit)
|
||
- [x] Platform contract (`lib/core/platform/health_gateway.dart`): Phase 3B surface added — `HealthProviderType` (appleHealth, healthConnect, samsungHealth, enterpriseFhir, manualFallback), `HealthConnectionStatus` (notConfigured, permissionDenied, connected, unsupportedPlatform), `HealthDataPayload` (weightLbs / activeCaloriesToday / bloodGlucoseMgDl / timestamp / source), and `IHealthGateway.checkStatus` / `requestAuthorization` / `fetchLatestBiometrics` / `disconnect` with defaults derived from the existing primitives. NOTE for future implementors: Dart `implements` never inherits method bodies — drivers and test fakes must `extends IHealthGateway` (or reimplement the members) to pick up the defaults.
|
||
- [x] Platform (mobile driver): `BLOOD_GLUCOSE` added to the iOS/Android `package:health` read sets (mmol/L → mg/dL normalization ×18.0182); `checkStatus` probes `hasPermissions` + Health Connect SDK readiness before answering; `samsungHealth` aliases the Health Connect binding on Android (federated Samsung Health + Pixel sensor feeds); `disconnect` clears the cached SDK probe (neither HealthKit nor Health Connect exposes programmatic revocation — OS-level revoke stays in system settings); `fetchLatestBiometrics` aggregates today's weight (latest), active energy (sum), and blood glucose (latest).
|
||
- [x] Platform (fallback driver): inherits the Phase 3B defaults — manualFallback → `connected`, enterpriseFhir → `notConfigured`, native providers → `unsupportedPlatform`; `fetchLatestBiometrics` composes the encrypted drift cache via `ILocalBiometricReader` with source `manualFallback`; zero platform-channel calls on Web/Windows/macOS (safe dummy states, never a crash).
|
||
- [x] Presentation (`IntegrationsController` rewrite): status-based orchestration replacing the old toggles — `refreshStatuses()` probes all three providers on build (disposal-flag guarded; Riverpod 2.x `Ref` has no `mounted`), `connectAppleHealth`/`connectHealthConnect` run `requestAuthorization` round-trips (denial → `permissionDenied` + inline snackbar), `disconnectProvider` tears down via the gateway and re-probes.
|
||
- [x] Presentation (`SettingsScreen`): `Health Integrations` card with Apple Health, Health Connect (Samsung & Pixel), and SMART on FHIR rows — live status badges (Connected / Not connected / Permission denied / Unsupported) and Connect/Disconnect authorization buttons; desktop/web render disabled Connect buttons + neutral grey "Unsupported" badges (desktop-safe states, no dead platform channels); the FHIR row stays inert until its OAuth2 connector is provisioned. Identity (Supabase sign-out), Targets, and SQLCipher status cards unchanged; `/settings` route wiring unchanged.
|
||
- [x] Manifests: Android — `android.permission.health.READ_BLOOD_GLUCOSE` added to the Health Connect permission set, `com.google.android.apps.healthdata` package query + `ACTION_SHOW_PERMISSIONS_RATIONALE` intent in `<queries>`, and `ViewPermissionUsageActivity` activity-alias (`VIEW_PERMISSION_USAGE` / `HEALTH_PERMISSIONS`) per architecture §4.7.4; iOS — `NSHealthShareUsageDescription` + `NSHealthUpdateUsageDescription` added to `ios/Runner/Info.plist`.
|
||
- [x] Tests: +13 — fallback driver dummy-state matrix (`fallback_health_gateway_test.dart`, 5), integrations controller connect/disconnect/status transitions (`integrations_controller_test.dart`, 6), Settings screen desktop disabled-states + mobile connect/disconnect badge round trip (`settings_screen_test.dart`, 2). Existing `_FakeHealthGateway` switched to `extends` for the inherited defaults; `app_navigation_test.dart` updated for the renamed "Health Integrations" card title.
|
||
|
||
## Phase 3B Verification Gates (2026-10-17)
|
||
- [x] `dart run build_runner build --delete-conflicting-outputs` (191 outputs written)
|
||
- [x] `flutter analyze` (No issues found!)
|
||
- [x] `flutter test` (148/148 passing)
|
||
- [x] `flutter build windows --debug` (`✓ Built build\windows\x64\runner\Debug\glpcompanion.exe`; the Settings screen's desktop-safe disabled states are asserted headlessly by `settings_screen_test.dart` — interactive `flutter run -d windows` compiles the same toolchain)
|
||
|
||
## Active Verification Gates (Phase 7, 2026-10-16)
|
||
- [x] `dart analyze lib test` (Zero issues)
|
||
- [x] `flutter test` (all passing — 125/125)
|
||
- [x] `flutter run -d windows` — builds cleanly; all 7 rail destinations switch without overflow (integration tests assert this headlessly)
|
||
|
||
## Phase 6 Verification Gates (2026-10-16, retained)
|
||
- [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)
|
||
|
||
## Post-Phase-7 Additions
|
||
- [x] Deep-link (OAuth / magic-link callback) registration across platforms (2026-10-17): the custom scheme `com.companionglp.glpcompanion` (mirrors the bundle identifiers behind `kAuthCallbackRedirectUrl`) is now registered in every OS routing table —
|
||
- iOS: `Runner/Info.plist` `CFBundleURLTypes` (`CFBundleTypeRole=Editor`, scheme `com.companionglp.glpcompanion`).
|
||
- macOS: `Runner/Info.plist` `CFBundleURLTypes` (same shape).
|
||
- Android: `VIEW`/`DEFAULT`/`BROWSABLE` intent-filter with `android:scheme="com.companionglp.glpcompanion"` on `.MainActivity` (existing `singleTop` launch mode routes `onNewIntent` to the running activity consumed by `app_links`).
|
||
- Windows runner: `RegisterAppLinkProtocol()` (windows/runner/utils.cpp) writes the per-user protocol under `HKCU\Software\Classes\com.companionglp.glpcompanion` (`URL Protocol` + `shell\open\command` → `"<exe>" "%1"`, no elevation, re-registered every launch); `wWinMain` calls `SendAppLinkToInstance()` (exported by the `app_links` Windows DLL) before creating a window so a protocol activation while the app is running forwards the URI to the existing instance over `WM_COPYDATA` instead of spawning a second window. Cold-start links continue to be read from the command line by the plugin.
|
||
- [x] macOS entitlements fix (pre-existing gap, required for the OAuth round trip): `com.apple.security.network.client` added to `DebugProfile.entitlements` and `Release.entitlements` — the sandboxed macOS runner previously had no outbound-network entitlement, so Supabase (including the auth exchange the deep link feeds) was unreachable.
|
||
- [x] `lib/features/auth/data/supabase_auth_service.dart` doc comment updated to reflect the actual registration points.
|
||
- [x] Windows OAuth handoff diagnostics + no-hang guarantees (2026-10-17, after field report: chooser appears then "nothing happens"):
|
||
- Runner logs every protocol-activation step to `%TEMP%\glpcompanion-protocol.log` (and `OutputDebugString`): launch argv, `SendAppLinkToInstance()` outcome, and — in the original instance — every `WM_COPYDATA` receipt with `dwData`/payload (expected `dwData=1026` = `WM_USER+2`, the app_links tag).
|
||
- Fallback path hardened: when forwarding fails (e.g. UIPI drops `WM_COPYDATA` to an elevated target, or no instance found) the fresh instance now shows + foregrounds its window (taskbar flash fallback) and completes the code exchange itself via the plugin's pending-link path — protocol activation can no longer look like a hang.
|
||
- Dart: `package:logging` root listener (kDebugMode) added in `lib/main.dart` so supabase_flutter's auth/deeplink diagnostics (`handle deeplink uri`, `Error while getSessionFromUrl`) are visible under `flutter run`; `logging: ^1.3.0` promoted to a direct dependency in `pubspec.yaml`.
|
||
- [x] Deferred auth-error surfacing (2026-10-17, after Microsoft-provider field failure looked like a "hang"): OAuth/magic-link handshake failures arrive *late* — the browser returns an `?error=...` callback, GoTrue's `notifyException` emits them as stream errors on `onAuthStateChange`, and nothing rendered them. `LoginScreen` now also listens to `authStateProvider` errors (router guard is unaffected — it reads `isAuthenticatedProvider`/live session directly, only bumping its refresh counter) and `_messageFor` translates the known "Error getting user email from external provider" failure into actionable guidance. Field-verified via the protocol log: the Windows WM_COPYDATA handoff (`dwData=1026` forwarded to the running instance) is confirmed working end-to-end.
|
||
- [x] Microsoft (Entra ID) provider handshake fixed (2026-10-17): the `server_error` / "Error getting user email from external provider" bounced back to the app was resolved by adding the optional `email` ID claim to the Entra app registration (`b8852225-23a1-46ef-a28e-365d8ef1cb3f`) in the Azure portal (Token configuration → Optional claims). Field-verified: the next Microsoft round trip returned a clean `?code=8432da29-...` callback (protocol log, 14:40:59) delivered to the running instance through the app_links handoff.
|
||
- [x] Single-instance enforcement (2026-10-17): Supabase Auth server logs (`POST /token` grant_type=pkce → 200 at 18:40:59Z, `user_signedup` for `mtkell@kellengineering.com`) proved the Microsoft exchange completed — in the *wrong window*. Two instances were running (protocol log: launches at 14:16 AND 14:37); the PKCE verifier lives in shared secure storage under one fixed key, so the callback was forwarded (Z-order) to the non-initiating instance, which silently completed the exchange server-side while the user's window stayed on the login screen. Fix: `AcquireSingleInstanceMutex()` (`Local\GLPCompanionSingleInstance`) in the Windows runner — duplicate normal launches activate the existing window and exit; protocol activations still forward to the sole window. Callback delivery is now deterministic.
|
||
- [x] Reactive auth-guard state (2026-10-17): `isAuthenticatedProvider` read the non-reactive `currentSession` getter inside a plain `Provider`, caching its first-read value forever (nobody invalidated it). Booting already-signed-in cached `true`, so sign-out cleared the session (Identity card → "Not signed in", button disabled) but the guard kept passing — no redirect to `/login`; live sign-in navigation was equally broken but masked because every successful entry had been a boot-time session. Fixed by `ref.watch(authStateProvider)` inside the provider so the boolean is re-derived on every GoTrue transition; test overrides (`overrideWithValue`) bypass the body and are unaffected. Hardened further: the router's `refreshListenable` bump is deferred via `Future.microtask` so the redirect guard can never re-read `isAuthenticatedProvider` inside Riverpod's provider-notification cascade (ordering edge where a stale cached value could be observed); field log confirmed the `signedOut` event and local session removal fire correctly, isolating the issue to navigation only.
|
||
- [x] End-to-end auth field verification (2026-10-17, user-confirmed): all three passwordless modes exercised live on Windows — Google OAuth, Microsoft (Entra ID) OAuth, and email magic link — each completing the custom-scheme round trip (`com.companionglp.glpcompanion://login-callback/`) through the single-instance `WM_COPYDATA` handoff into a persisted session; sign-out now redirects to `/login` and re-authentication re-enters the app without a relaunch.
|
||
- [x] Desktop cloud health sync — Fitbit connector (2026-10-17, architecture rule 3 "third-party OAuth REST sync" realized). SUPERSEDED THE SAME DAY — Google retired the legacy Fitbit Web API (support ended 2026-09-30, full shutdown 2026-10-30); the connector was replaced with the Google Health API connector below before ever shipping, so no legacy Fitbit code remains in the tree:
|
||
- Core: `ICloudHealthConnector` contract (`lib/core/platform/cloud_health/`), `supportsCloudSync` on `IHealthGateway`, and a `HealthProviderType.fitbit` slot. Conditional-import discipline preserved: `fitbit_connector.dart` shims to the io implementation on `dart:io` platforms and an unsupported stub on Web (zero platform channels on web, same as the rest of the fallback driver).
|
||
- Fitbit connector (`fitbit_connector_io.dart`): OAuth2 authorization-code + PKCE (S256) through the system browser with an RFC 8252 loopback redirect (`http://localhost:52789/callback`, port configurable via `FITBIT_REDIRECT_PORT`); tokens in flutter_secure_storage with transparent refresh + 401 retry; weight logs paged in 30-day windows and normalized kg → lb (unit detected via the profile-height heuristic — cm heights always >100, inches ≤90); active calories from today's activity summary (`activityCalories`). Pure payload parsers isolated in `fitbit_parser.dart` for unit testing.
|
||
- Wiring: `FallbackHealthGateway` accepts injected connectors, merges cloud reads with the local drift cache (newest-first), and routes checkStatus/authorize/disconnect per provider; `createHealthGateway` threads the list through; the mobile driver ignores it (cloud providers read `unsupportedPlatform` via the interface defaults). `syncFromHealth` now runs when EITHER a native SDK or a cloud connector is available (native permission prompt only for native), persisting imports as `BiometricSource.cloudSync` (enum value that already existed for this purpose).
|
||
- UI: Fitbit row in Settings → Health Integrations (Connect runs the browser OAuth; on success the biometrics import immediately; Disconnect clears stored tokens). Desktop explanatory copy added to the Apple Health / Health Connect rows ("Available on iOS/Android — on desktop use Log Weight on the Nutrition screen") so the disabled states read as graceful degradation, not breakage.
|
||
- Provider decisions: **Withings** = same `ICloudHealthConnector` pattern, deferred to a follow-up (needs its own developer app + credentials). **MyFitnessPal** = NOT viable: no public API since 2018; a CSV/manual import would be the only route and is not planned.
|
||
- Configuration (not in source control): register an app at dev.fitbit.com (OAuth 2.0, Personal type), callback URL `http://localhost:52789/callback`, scopes `weight activity`; put `FITBIT_CLIENT_ID`, `FITBIT_CLIENT_SECRET`, optionally `FITBIT_REDIRECT_PORT` in `.env`. Direct deps added: `url_launcher`, `crypto`.
|
||
- Tests: `test/core/platform/fitbit_parser_test.dart` (metric/imperial conversion, window filtering, malformed-payload tolerance, string/number calories, height heuristic); `settings_screen_test.dart` updated for the fourth integration row.
|
||
|
||
- [x] Google Health API migration (2026-10-17): the Fitbit connector was replaced with `GoogleHealthConnector` against the successor API before shipping — official docs (developers.google.com/health) confirm the legacy Fitbit Web API shuts down 2026-10-30 with migration to Google Health required:
|
||
- Connector: same `ICloudHealthConnector` framework; OAuth2 (authorization-code + PKCE, `access_type=offline&prompt=consent` for a stable refresh token) via `accounts.google.com` with the same loopback redirect; scopes `googlehealth.activity_and_fitness.readonly` + `googlehealth.health_metrics_and_measurements.readonly`; tokens under `google_health.oauth.tokens` with transparent refresh (keeps the previous refresh token when Google omits it on re-consent).
|
||
- Data: `GET health.googleapis.com/v4/users/me/dataTypes/{weight|active-energy-burned}/dataPoints` with `pageSize`/`nextPageToken` pagination; client-side window filtering (no server filter dependency); pure parser (`google_health_parser.dart`) normalizes kg → lb via the per-point `unit` field and reads `instantaneousTime` or `timeInterval.startTime`.
|
||
- Renames: enum `HealthProviderType.googleHealth`, provider `createGoogleHealthConnector()`, controller `connectGoogleHealth()`, Settings row "Google Health — Fitbit, Pixel & Wear OS data (replaces the Fitbit API)"; all legacy Fitbit files, parser, and tests deleted; env keys `GOOGLE_HEALTH_CLIENT_ID` / `GOOGLE_HEALTH_CLIENT_SECRET` (+ optional `GOOGLE_HEALTH_REDIRECT_PORT`); tests: `google_health_parser_test.dart` (kg/lb, interval vs instantaneous time, windowing, malformed payloads, kcal passthrough).
|
||
- Google Cloud setup: project → enable Google Health API → OAuth consent screen (External; **Testing status limits refresh tokens to 7 days — publish the app to production status for durable tokens**) → OAuth client (Web application) with Authorized redirect URI `http://localhost:52789/callback` → copy ID/secret into `.env`.
|
||
|
||
## Google Health Verification Gates (2026-10-17, to run locally)
|
||
- [ ] `flutter pub get`, `flutter analyze`, `flutter test` (parser suite + updated settings row counts).
|
||
- [x] Google Health row wiring fix (2026-10-17, field-reported: row rendered "Unsupported" with a disabled Connect button on Windows): the `dart.library.io` conditional import routes **every io platform — including desktop — to `MobileHealthGateway`** (the fallback driver is Web-only, despite its doc comment implying desktop). The mobile driver accepted `cloudConnectors` for signature parity but discarded them, so `checkStatus(googleHealth)` fell through to the interface default → `unsupportedPlatform`. Fix: `MobileHealthGateway` now stores the connectors, exposes `supportsCloudSync`, routes checkStatus/authorize/disconnect to them first, and merges cloud reads with native reads (`Future.wait`, non-fatal on provider errors) — desktop gains its only sync source, mobile gains an optional supplement. Web behavior unchanged (stub → unsupported). Merge correction: the driver already implemented the five methods further down the class; the duplicates were removed and the existing implementations edited instead (compile-error round-trip).
|
||
- [x] Weight-card sync gate (2026-10-17, field-reported: "Log Weight / Sync" always opened the manual dialog on desktop): `_logWeightOrSync` gated on `supportsNativeHealth` alone (predating cloud sync). Now: the button syncs when a native SDK exists OR any cloud partner holds a valid grant (new `IHealthGateway.isCloudSyncReady()` probes connector statuses; both drivers override), falls back to the manual dialog otherwise (the dedicated "Manual" button is unchanged), and the sync outcome (`Synced N weight sample(s)…`) is surfaced as a snackbar. The card's sync icon now also appears when cloud sync is wired.
|
||
- [ ] Manual Google Health round trip on Windows: Google Cloud credentials in `.env` → Settings → Google Health → Connect → Google consent → loopback redirect → row flips to Connected → weight/active energy appear on the Nutrition dashboard as `cloudSync` entries. (Test data: log a weight in the Google Health app first — the API has no sandbox data.)
|
||
|
||
## Deep-Link Verification Gates (2026-10-17)
|
||
- [x] Windows runner handoff — FIELD-PROVEN via `%TEMP%\glpcompanion-protocol.log`: protocol registration → OS chooser → second process launches with the link in argv → `WM_COPYDATA` (dwData=1026, the app_links tag) delivered to the running instance → clean forwarder exit. Observed on both the Google and Microsoft success round trips, plus the earlier `?error=` path (now surfaced on-screen by the login screen). No runner build gate remains — the running binary is the gate.
|
||
- [ ] `flutter build apk --debug` / `flutter build ios --no-codesign` / `flutter build macos` — manifests/plists parse and build cleanly (needs a machine with the mobile toolchains; also exercises the iOS/macOS `CFBundleURLTypes` and Android intent-filter additions).
|
||
- [x] Supabase Auth redirect allow-list configured (2026-10-17, via Supabase Management API on project `pyrgjdvqbjzakuwvbpxw` "glp-companion"): `uri_allow_list` now = `io.supabase.glpcompanion://login-callback,http://localhost:3000/**,com.companionglp.glpcompanion://login-callback,com.companionglp.glpcompanion://**` (previous entries preserved; stale `io.supabase.*` scheme kept for rollback). Post-write GET verified no collateral changes. `site_url` intentionally left at `http://localhost:8080` — update to `https://companionglp.com` once the web deployment is live. |