glp-companion/BUILD_STATE.md

199 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 4B — Private Recipe Sources & Mealie Integration (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 3C: Nutritional Search Engine, Barcode Scanning & Dual-Source Food Cache (architecture §4.8 resolution ladder)
- [x] Phase 4: Recipe Web Scraper Engine & Meal Planning Calendar
- [x] Phase 4B: Private Recipe Sources & Mealie Integration (architecture §4.9 — self-hosted Mealie sync)
- [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)
## Phase 3C Deliverables (2026-10-17, architecture §4.8 retrofit)
- [x] Dependencies: `openfoodfacts ^3.7.0` (resolves 3.31.0) + `mobile_scanner ^5.2.3` added to `pubspec.yaml` (`http ^1.2.1` already present); `.env` gains `USDA_FDC_API_KEY=DEMO_KEY` (read with an `isInitialized` guard + DEMO_KEY fallback in `UsdaFdcClient.apiKey`).
- [x] Domain: `FoodProduct` entity (id, barcode, productName, brand, servingSizeGrams/Unit, calories, protein/carbs/fat/fiber/sodium, imageUrl, lastUpdated) and `IFoodSearchRepository` contract (searchByText / lookupBarcode / cacheProduct) — pure Dart.
- [x] Data (persistence): `FoodProductsTable` added to `AppDatabase` (schema v5 + `from < 5` migration; text PK `id`, nullable barcode/brand/imageUrl, real macro columns, datetime `lastUpdated`) with barcode/substring search helpers (LIKE-wildcard sanitized) and `upsertFoodProduct` (`insertOnConflictUpdate`).
- [x] Data (sources): `UsdaFdcClient` (POST `/fdc/v1/foods/search`, nutrient-ID mapper 1008/1003/1004/1005/1079/1093, branded GTIN/UPC mode for barcode lookups, injectable `http.Client`) and `OpenFoodFactsClient` (`OpenFoodAPIConfiguration.userAgent` with app identity + contact, `SearchTerms` text search, `getProductV3` barcode lookup, per-serving nutrient reads with per-100 g scaling) — both implement the shared `RemoteFoodDataSource` interface for testability.
- [x] Data (repository): `FoodSearchRepository` implements the §4.8 ladder — L1 local drift cache (substring/barcode) → L2 Open Food Facts (4-second timeout) → L3 USDA fallback → write-through cache via `insertOnConflictUpdate`; source failures degrade non-fatally (`debugPrintStack`).
- [x] Presentation: `FoodSearchController` (StateNotifier; 400 ms debounced text queries, immediate barcode lookups, request-token supersession, loading/error/empty states) and `FoodSearchScreen` (debounced search bar with clear button; AppBar scan button — `MobileScanner` modal sheet on iOS/Android with UPC-A/EAN-8/13 filters and permission-failure messaging, manual UPC dialog + desktop/web notification otherwise; product tiles with thumbnail/name/brand/calories/protein badge; action bottom sheet with full macros, 0.5×/1×/1.5×/2× serving multiplier, "Log to Diary" → `FoodLogsTable` via `NutritionController.logFood`, "Add to Pantry" → `PantryItemsTable` via `PantryInventoryController.addItem`).
- [x] Integration: `/food-search` route (top-level, above the shell) with `?preset=pantry`; Nutrition screen gains a "Search Food / Scan Barcode" entry button; Pantry screen inventory tab gains an AppBar "Scan item barcode" action routing to the pantry preset (primary action flips to "Add to Pantry").
- [x] Tests: +10 in `test/features/nutrition/food_search_repository_test.dart` — cache hit short-circuit, OFF-first + write-through cache, USDA fallback on OFF failure/empty, dual-miss empty results, second-lookup cache hit, blank-query short-circuit, and the same hierarchy for barcode lookups (cached barcode, OFF barcode hit, USDA branded-GTIN fallback, total-failure degradation).
- [x] Regression fix (pre-existing, gate-unblocking): `GoogleHealthConnector` dotenv reads now go through an `isInitialized`-guarded `_env()` helper — `app_navigation_test.dart` previously crashed with `NotInitializedError` whenever `checkStatus` ran in a test/production shell that never called `dotenv.load`.
## Phase 3C Verification Gates (2026-10-17)
- [x] `flutter pub get` (openfoodfacts 3.31.0 + mobile_scanner 5.2.3 resolved)
- [x] `dart run build_runner build --delete-conflicting-outputs` (206 outputs; `FoodProductsTable` codegen verified in `app_database.g.dart`)
- [x] `flutter analyze` (No issues found!)
- [x] `flutter test` (164/164 passing, incl. 10 new fallback-sequence tests)
- [x] Field fix (2026-10-17, first live run): "food search returns nothing" — two server-side contract violations, both diagnosed with raw HTTP probes:
- **USDA 403 `API_KEY_MISSING`**: the FDC API validates the key from the **query string only** (`?api_key=…`); the JSON-body `apiKey` field is silently ignored. `UsdaFdcClient` now builds the URI with `queryParameters: {'api_key': apiKey}` and drops the body field (plus sends an identifying `User-Agent`); missing-unit records now default to `g` (FDC's per-100 g basis) instead of `count`.
- **Open Food Facts 503**: OFF blocks all anonymous **search** endpoints server-side (both `cgi/search.pl` and `api/v2/search` 503 even from raw HTTP with a proper User-Agent) while product/barcode lookups return 200 — the USDA fallback is the designed path for text search and now carries it. Barcode scans still prefer OFF.
- UX hardening: the repository now throws `FoodSearchUnavailableException` when the cache misses AND every remote source *errored* (vs. answered "no matches"), so the screen shows "Search failed — check your connection" instead of a false "No products found".
- Regression tests: `test/features/nutrition/usda_fdc_client_test.dart` (+5, MockClient) locks the query-param key shape, DEMO_KEY fallback, nutrient-ID mapping (mg sodium passthrough), Branded-only barcode mode, and non-200 → `UsdaFdcException`; the total-failure repository test now asserts the throw. Full suite: 170/170.
- [ ] `flutter build apk --debug` / `flutter build ios --no-codesign` — needs a machine with the mobile toolchains (also exercises the Android/iOS camera-permission additions required by `mobile_scanner`)
- [ ] Live dual-source round trip on a device: scan a known UPC → OFF hit populates `FoodProductsTable` → airplane-mode relaunch → same barcode resolves from the SQLCipher cache; delete the row + query a generic food (e.g. "rolled oats") → USDA fallback populates the cache.
## 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.
## Phase 4B Deliverables (2026-10-17)
- [x] Security: `CryptoManager` gains `mealie_base_url` / `mealie_api_token` vault keys plus `writeMealieCredentials()` / `readMealieCredentials()` / `deleteMealieCredentials()` — the pairing persists only in the platform keystore (Keychain / EncryptedSharedPreferences / DPAPI) and never enters controller state; `cryptoManagerProvider` exported from `core/security/`.
- [x] Data: `MealieApiClient` (`lib/features/recipes/data/datasources/mealie_api_client.dart`) — `testConnection` probes `GET {base}/api/app/about` with a `GET {base}/api/recipes?perPage=1` fallback (HTTP 200 = connected); `fetchAllRecipes` lists `GET {base}/api/recipes?perPage=-1` (bare-array and `{items}` shapes) and enriches every summary with `GET {base}/api/recipes/{slug}` (detail failure degrades to the summary), mapping `recipeIngredient` (structured `{quantity, unit, food, note}` maps + legacy strings), `recipeInstructions` (`{name, text}` section flattening), `nutrition` (`"512 kcal"` / `"30.5 g"` string parsing → `MacroProfile`), ISO-8601 `prepTime`/`cookTime` → minutes, `recipeYield`/`recipeYieldQuantity` → servings; image `{base}/api/media/recipes/{id}/images/min-original.webp`, source `{base}/recipe/{slug}`; the Mealie UUID is reused as `Recipe.id` for idempotent upserts (local UUID v4 fallback); injectable `http.Client`, 15 s timeout, `MealieApiException`, static `isMealieSourceUrl` matcher (prefix + host fallback for scheme drift).
- [x] Data: `AppDatabase.upsertRecipe` (`insertOnConflictUpdate`) + `DriftRecipeRepository.syncFromMealie(baseUrl, token)` calling `MealieApiClient.fetchAllRecipes` and upserting normalized rows while preserving `createdAt` (returns the synced row count).
- [x] Presentation: `MealieIntegrationController` (`lib/features/recipes/presentation/controllers/mealie_integration_controller.dart`) — async vaulted-credential load, `testAndSave` (URL/token validation + connectivity-gated vault write returning inline errors), `syncNow` (spinner state, status/error outcome), `disconnect`, `clearMessages`, `isMealieRecipe` badge/filter helper; providers `mealieApiClientProvider`, `mealieSyncRepositoryProvider` (null when tests override the catalog with a fake), `mealieIntegrationControllerProvider`.
- [x] Settings: "Private Recipe Integrations" card — "Mealie Self-Hosted Instance" row with Connected/Disconnected status badge; disconnected → "Connect Mealie" modal dialog (Server URL field hint `https://mealie.kellfamilyhome.com`, obscured API Bearer Token field hint "Generate in Mealie under User Profile -> API Tokens", inline error text, "Test & Save" verifies connectivity before persisting credentials); connected → active endpoint display, "Sync Now" (progress spinner while syncing), "Disconnect"; screen-level listener surfaces status/error snackbars.
- [x] Recipe Vault: `RecipeSourceFilter` chips [All / Web Scraped / Mealie] above the macro chips, subtle Mealie `restaurant_menu` badge with tooltip ("Synced from your Mealie server") on synced cards, and an AppBar "Sync Private Recipes" icon action (spinner while syncing) rendered only when Mealie is configured.
- [x] Regression fix: `_SettingsCard` title Row wraps its `Text` in `Expanded` — the new, longer section title overflowed by 91 px at 390 dp (caught by the mobile navigation widget test, which renders with the Ahem test font where every glyph advance equals the font size); all card titles now shrink/wrap instead of overflowing narrow layouts.
- [x] Tests: `test/features/recipes/mealie_parser_test.dart` — 14 tests: identity/media/times/yield mapping, structured + string ingredient mapping, ingredient JSON serialization round-trip (`toMap`/`fromMap`), instruction flattening, nutrition string parsing, UUID-v4 fallback, null-title rejection, listing/detail request shape + Bearer auth header, summary degradation on detail failure, 401 → `MealieApiException`, connection probes (about / fallback / unreachable / empty input), `isMealieSourceUrl` matching incl. scheme drift.
## Phase 4B Verification Gates (2026-10-17)
- [x] `flutter analyze` — No issues found.
- [x] Codegen: `dart run build_runner build` (`flutter pub run` is retired on current Flutter; `--delete-conflicting-outputs` is now ignored by build_runner) — drift re-emits `app_database.g.dart` byte-identically (no schema change).
- [x] `flutter test` — 184/184 green (170 pre-existing + 14 new Mealie tests), including the 390 dp mobile navigation suite.
- [ ] Manual Mealie round trip on a reachable instance: Settings → Private Recipe Integrations → Connect Mealie (URL + API token) → Test & Save → badge flips to Connected → Sync Now (or the Recipe Vault AppBar sync action) → vault shows the Mealie badge and the Mealie/Web Scraped filter chips isolate each source.