glp-companion/BUILD_STATE.md

66 KiB
Raw Blame History

Build Execution Ledger

Active Target

  • Current Milestone: Phase 7: Navigation & Domain Expansion (Exercise, Recipe Vault, Settings) — COMPLETE
  • Status: Phase 7 Complete (all phases 1–7 done); alpha release pipeline landed 2026-10-18
  • Latest Addendum: Alpha Release Pipeline & Scripting (2026-10-18)

Phase Checklist

  • Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB)
  • Phase 2: Medication Domain, Injection Rotation, Serum Modeling
  • Phase 3: Nutrition, Protein Target Tracking, HealthGateway Sync
  • Phase 3B: Platform Health Integrations & HealthGateway (architecture §4.7 contract, Settings control surface, native entitlements)
  • Phase 3C: Nutritional Search Engine, Barcode Scanning & Dual-Source Food Cache (architecture §4.8 resolution ladder)
  • Phase 4: Recipe Web Scraper Engine & Meal Planning Calendar
  • Phase 4B: Private Recipe Sources & Mealie Integration (architecture §4.9 — self-hosted Mealie sync)
  • Phase 5: Pantry Delta Engine & Auto-Shopping Generator
  • Phase 6: Hardening, Native Desktop/Web Polish, CI/CD
  • Phase 7: Navigation & Domain Expansion (Exercise, Recipe Vault, Settings)

Phase 2 Deliverables (2026-10-16)

  • Domain: DoseEntry entity, 12-quadrant InjectionSite rotation model, Glp1Compound value object (Sema 168h / Tirze 120h / Lira 13h), IMedicationRepository contract.
  • Domain: CalculateSerumLevel use case implementing C(t) = Σ D_i · 0.5^((t − t_i)/t_half).
  • Data: injection-history CRUD on AppDatabase + drift-backed MedicationRepository (record ↔ entity mapping).
  • 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).
  • 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.
  • Regression fix: SupabaseAuthService now resolves its client lazily (baseline widget test no longer throws when Supabase is uninitialized).
  • 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

  • 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)

  • Domain: FoodLogEntry (+ MealType enum), HydrationLogEntry, BiometricEntry (+ BiometricSource enum), INutritionRepository, IHealthSyncRepository — all pure Dart.
  • 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).
  • 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).
  • 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.
  • 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.
  • 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).
  • Regression fix: none required — existing 37 tests untouched and green.

Phase 4 Deliverables (2026-10-16)

  • 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).
  • 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.
  • 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).
  • 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).
  • 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.
  • 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).
  • 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

  • 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)

  • 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).
  • 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.
  • 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).
  • 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).
  • Presentation: PantryScreen refactor — Inventory/Shopping tab header with remaining-count badge, Aisles/Expiration segmented control, categorized tiles with +/− counters and Expired/expiring badges, swipe-to-delete on both lists, prominent "Sync with Meal Plan" banner with run summary + snackbar, auto-row "From: " origin chips, filter segments, check-off → pantry prompt, and Add Pantry Item / Add Shopping Item dialogs.
  • 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)

  • 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).
  • 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.
  • 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.
  • Testability: isAuthenticatedProvider extracted in app_router.dart so integration tests can simulate authenticated/unauthenticated sessions without booting Supabase.
  • 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).
  • 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.
  • 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.
  • 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).
  • 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.
  • 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/.
  • 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)

  • Domain: MuscleGroup enum + ExerciseEntry entity (pure Dart), IExerciseRepository contract, session-scoped InMemoryExerciseRepository (drift table deferred to a follow-up migration).
  • 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).
  • 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).
  • 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.
  • 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).
  • 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).
  • 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

  • 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.
  • 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).
  • 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.
  • 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

  • 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)

  • 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.
  • 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).
  • 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).
  • 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.
  • 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.
  • 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.
  • 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)

  • dart run build_runner build --delete-conflicting-outputs (191 outputs written)
  • flutter analyze (No issues found!)
  • flutter test (148/148 passing)
  • 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)

  • 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).
  • 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.
  • 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).
  • 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.
  • 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).
  • 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).
  • 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").
  • 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).
  • 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)

  • flutter pub get (openfoodfacts 3.31.0 + mobile_scanner 5.2.3 resolved)
  • dart run build_runner build --delete-conflicting-outputs (206 outputs; FoodProductsTable codegen verified in app_database.g.dart)
  • flutter analyze (No issues found!)
  • flutter test (164/164 passing, incl. 10 new fallback-sequence tests)
  • 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)

  • dart analyze lib test (Zero issues)
  • flutter test (all passing — 125/125)
  • 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)

  • dart run build_runner build --delete-conflicting-outputs (44 outputs written)
  • flutter analyze --fatal-infos (Zero issues — No issues found!)
  • flutter test (123/123 passing)
  • flutter build web --no-tree-shake-icons (conditional import keeps package:health/dart:io out of the web bundle)

Post-Phase-7 Additions

  • 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.
  • 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.

  • lib/features/auth/data/supabase_auth_service.dart doc comment updated to reflect the actual registration points.

  • 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.
  • 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.

  • 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.

  • 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.

  • 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.

  • 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.

  • 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.
  • 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).
  • 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).
  • 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.)
  • 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).
  • 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)

  • 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/.
  • 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).
  • Data: AppDatabase.upsertRecipe (insertOnConflictUpdate) + DriftRecipeRepository.syncFromMealie(baseUrl, token) calling MealieApiClient.fetchAllRecipes and upserting normalized rows while preserving createdAt (returns the synced row count).
  • 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.
  • 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.
  • 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.
  • 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.
  • 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)

  • flutter analyze — No issues found.
  • 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).
  • 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.

Alpha Release Pipeline & Tooling (2026-10-18)

Release engineering for alpha (pre-release) builds across Android and Windows, driven either locally (one-shot PowerShell script) or by CI when a version tag is pushed.

  • CI: .forgejo/workflows/release.yaml — "Build and Publish Alpha Releases", triggered on push of tags matching v*. Three jobs:
    • build-android (ubuntu): Java 17 (Temurin) + Flutter 3.47.x → flutter pub get → dart run build_runner build --delete-conflicting-outputs → decode ANDROID_KEYSTORE_BASE64 / ANDROID_KEY_PROPERTIES repo secrets into android/app/upload-keystore.jks + android/key.properties (step skipped when secrets are absent) → flutter build apk --release --split-per-abi → stages GLP1Companion-Android-arm64-<tag>.apk under dist/.
    • build-windows (windows): Flutter 3.47.x → flutter pub get + codegen → flutter build windows --release → dart run msix:create --install-certificate false → stages GLP1Companion-Windows-<tag>.msix and GLP1Companion-Windows-x64-<tag>-Portable.zip (runner Release folder zipped via Compress-Archive).
    • publish-release (needs both build jobs): downloads the staged artifacts and drives the Forgejo REST API (POST /api/v1/repos/{owner}/{repo}/releases, then /releases/{id}/assets?name=) with GITHUB_TOKEN to create the release with prerelease: true — falling back to the existing release for the tag if creation fails — and uploads every .apk / .msix / .zip.
    • Flutter pinned to 3.47.x to match the dev toolchain: pubspec.lock declares flutter: ">=3.44.0", so an older CI toolchain (e.g. the originally drafted 3.24.x) fails at flutter pub get.
  • Local script: scripts/publish_alpha.ps1 -Version <x.y.z> [-ForgejoToken <token>] (token defaults to $env:FORGEJO_TOKEN; server https://git.kellsupport.com, repo mtkell/glp-companion) — same flow end to end: codegen → arm64 APK → portable zip → MSIX → Forgejo release (prerelease: true) with asset upload; artifacts land in dist/v<version>/.
  • Forgejo API probe: scripts/test.ps1 verifies release lookup/creation and multipart/form-data asset upload against a throwaway draft release (v0.0.1-test) with a dummy text asset; the token is read from $env:FORGEJO_TOKEN only (the previously hardcoded token was removed — rotation tracked in the gates below).
  • Android release signing: android/app/build.gradle.kts loads android/key.properties from the project root when present and wires a release signing config (alias / password / store) with isMinifyEnabled + isShrinkResources and proguard-rules.pro; when the file is absent the properties are empty (CI decodes them from secrets; local release builds need their own key.properties).
  • ProGuard rules (android/app/proguard-rules.pro): keep Flutter embedding/plugin/util/view classes, net.sqlcipher.** + net.sqlcipher.database.** (SQLCipher backing the encrypted drift DB), annotation/enum/signature metadata for JSON serialization, plus -dontwarn suppressions for Play Core deferred components, conscrypt, and javax.annotation.
  • MSIX packaging: msix: ^3.16.7 dev dependency (resolved 3.18.0) + msix_config block in pubspec.yaml — display name "GLP-1 Companion", publisher "Kell Engineering LLC" (CN=Kell Engineering LLC, O=..., L=Valrico, S=Florida, C=US), identity KellEngineering.GLP1Companion, msix_version 0.1.0.0, logo_path: web/icons/Icon-512.png, internetClient capability, execution alias glp-companion.
  • Secret & artifact hygiene: /dist/ (local release artifacts, ~107 MB), **/android/build/ (Gradle problems report), and **/android/key.properties added to .gitignore; the hardcoded Forgejo token stripped from scripts/test.ps1 before it could ever reach git history.

Alpha Release Verification Gates (2026-10-18, to run)

  • Rotate the Forgejo token that was hardcoded in scripts/test.ps1: Forgejo → Settings → Applications → delete the exposed token, mint a new one, export it as FORGEJO_TOKEN locally, and store it as a repo secret. (The old token never entered git history, but it existed in plaintext on disk.)
  • Configure Forgejo Actions secrets on mtkell/glp-companion: ANDROID_KEYSTORE_BASE64 (base64 of upload-keystore.jks) and ANDROID_KEY_PROPERTIES (keyAlias / keyPassword / storeFile=upload-keystore.jks / storePassword). Without them the Android job's release build fails at signing.
  • Push a v* tag (e.g. git tag v0.1.0 && git push origin v0.1.0) and confirm all three workflow jobs go green and the prerelease at https://git.kellsupport.com/mtkell/glp-companion/releases carries the APK, MSIX, and portable zip assets.
  • Install-gate: sideload the arm64 APK on an Android device and install the MSIX on Windows; launch both and confirm the encrypted drift DB opens (the minify/shrink build is exactly why the SQLCipher keep-rules exist).

Post-Alpha Fixes (2026-10-18)

  • Android Google Health connect fix (field-reported alpha: authorization completed in the browser, but on return the app "only shows the localhost call" — Connect stayed highlighted and the row never flipped to Connected). Root cause: GoogleHealthConnector.authorize() ran the RFC 8252 loopback redirect on Android too — it bound HttpServer on the device loopback and needed the backgrounded app to serve Chrome's http://localhost:52789/callback after consent, which Android's cached-app freezer prevents; the callback never arrived, so authorize() hung (button busy) until the 5-minute timeout. Google deprecates loopback for mobile client types, and custom-scheme redirects are rejected on the existing Web-type client ("Custom scheme URIs are not allowed for 'WEB' client type"), so the fix follows Google's recommended Android path (Google Identity Services):
    • Connector (google_health_connector_io.dart): on Platform.isAndroid only, authorize() now runs package:google_sign_in (native account chooser + consent sheet — the app stays foreground, no HTTP callback) with serverClientId = GOOGLE_HEALTH_CLIENT_ID and forceCodeForRefreshToken: true (the prompt=consent equivalent), then exchanges the returned one-time serverAuthCode on the shared token endpoint (GIS codes carry no redirect_uri) — secure-storage tokens, transparent refresh, REST reads, and the parser are untouched. A 5-minute timeout mirrors the loopback path; a missing code logs the Android-client/SHA-1 checklist. disconnect() additionally signs the SDK out (best-effort) so Connect re-prompts for an account. Desktop/iOS keep the proven loopback flow.
    • UX: the Google Health row's failure copy is now OAuth-accurate — "Google Health sign-in was cancelled or did not complete. Tap Connect to try again." — instead of pointing the user at device settings (integrations controller branches on HealthProviderType.googleHealth).
    • Dependency: google_sign_in: 6.3.0 added (Android implementation 6.2.1).
    • Verified: flutter analyze clean; flutter test 184/184 green (including integrations_controller_test.dart and settings_screen_test.dart).
  • Android post-connect sync fix (field-reported right after the GIS fix: Google Health connected, but the app then showed "Health permissions were denied."): BiometricController.syncFromHealth() unconditionally demanded the native Health Connect grant first and bailed on denial — never reaching the cloud connector, which needs no OS permission. The bail is now !granted && !await gateway.isCloudSyncReady() — a native denial is fatal only when no cloud partner holds a grant; with Google Health connected, cloud reads proceed (native degrades to empty while ungranted) and the status reports Synced N weight sample(s)…. Tests: biometric_controller_test.dart gains a cloudReady fake override plus "native denial with a connected cloud partner still syncs partner data" (11 tests in file); full suite 185/185, analyze clean. Still prompts for Health Connect once — a denial there no longer breaks the cloud sync.

Google Health on Android — Verification Gates (2026-10-18, manual)

  • Google Cloud Android OAuth client(s) — DONE 2026-10-18 (the on-device connect succeeded, proving a matching package/SHA-1 client exists): Google Cloud → the same project as GOOGLE_HEALTH_CLIENT_ID → APIs & Services → Credentials → Create OAuth client ID → Application type Android: package name com.companionglp.glpcompanion with the SHA-1 of the release upload-keystore.jks (keytool -list -v -keystore upload-keystore.jks -alias <keyAlias>); add a second Android client with the debug keystore SHA-1 (%USERPROFILE%\.android\debug.keystore, alias androiddebugkey, storepass android) for debug builds. The GIS SDK resolves the caller by package/SHA-1 — without a match it returns no serverAuthCode and Connect fails (the connector logs the checklist in debug). Fingerprints computed 2026-10-18 (re-verify against the installed APK with keytool -printcert -jarfile <apk>): release upload-keystore.jks alias upload = 79:8B:73:52:70:77:C3:46:C7:12:06:88:42:0E:97:67:CC:B8:E2:85; debug ~/.android/debug.keystore alias androiddebugkey = 67:BA:FE:4A:15:5B:52:73:E1:8F:0D:F7:44:81:7B:5C:72:C3:A1:36.
  • On-device connect round trip (confirmed 2026-10-18): install the rebuilt arm64 APK → Settings → Google Health → Connect → native account chooser → Google consent (activity & fitness + health metrics read scopes) → returns straight into the app → row flips Connected.
  • Data round trip (pending an APK rebuild containing the post-connect sync fix above): after Connect, status reads Synced N weight sample(s)… instead of "Health permissions were denied.", and weight/active energy appear on the Nutrition dashboard (Android imports persist under the healthConnect platform source).

Health Connect (Samsung & Pixel) Row — Fix (2026-10-18)

  • Native Health Connect permission request failed silently (field-reported: tapping the "Health Connect (Samsung & Pixel)" row showed "Health permissions were denied. Enable them in your device settings" with no permission dialog ever appearing). Root cause per the package:health v13.3.1 README: requestAuthorization() registers its permission launcher via (activity as ComponentActivity).registerForActivityResult(...) in onAttachedToActivity — the host activity must therefore extend FlutterFragmentActivity, but MainActivity extended plain FlutterActivity (android.app.Activity, not androidx ComponentActivity). The cast fails → the launcher stays null → the plugin answers success(false) immediately (HealthPlugin.kt:317) → our gateway maps it to "denied". Three fixes:
    • MainActivity.kt now extends FlutterFragmentActivity (with a comment explaining the cast requirement). Drop-in for all other plugins (app_links, supabase, url_launcher, notifications, google_sign_in all bind to any Activity).
    • AndroidManifest.xml: added the README-required <intent-filter> for androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE on .MainActivity (the <queries> block and ViewPermissionUsageActivity alias were already present; health_connect_config.xml is optional restore tooling and was not added).
    • Read-set hygiene: STEPS removed from _androidReadTypes and READ_STEPS from the manifest — steps are never fetched anywhere in the app, and the README ties step access to the ACTIVITY_RECOGNITION permission this app does not declare; an ungrantable entry risks poisoning the whole permission request. Remaining set: weight, total calories burned, active energy burned, blood glucose (all four declared in the manifest).
    • Verified: flutter analyze clean; flutter test 185/185 green; flutter build apk --debug → Built build\app\outputs\flutter-apk\app-debug.apk (also clears the standing build-gate item for the debug APK; Gradle 135.1 s; pre-existing KGP warning for device_info_plus/health/mobile_scanner is non-fatal today).
  • On-device Health Connect round trip: tap the Samsung/Pixel row → the Health Connect permission screen appears (weight, calories, glucose toggles) → grant → row flips Connected → status shows a Synced … result. If a stale denial exists from earlier attempts, clear it once under Android Settings → Apps → glpcompanion → Permissions (or the Health Connect app's App permissions screen), then retry in-app.
  • Disconnect then Connect again re-prompts for an account (SDK sign-out path), and the row recovers cleanly if the sign-in sheet is dismissed (cancel → the OAuth-specific error banner, button un-busy).
  • Follow-up (not part of this fix): iOS still uses the loopback redirect and carries the same background fragility — migrate it to the SDK path when iOS alpha starts (needs an iOS-type OAuth client + reversed-client-ID URL scheme).
  • Web-client housekeeping (field-checked 2026-10-18: the console screenshot showed the WRONG client — the project has two Web clients): GOOGLE_HEALTH_CLIENT_ID = 305941286539-2bqe3cte3gg6b2m64k0uosdp3tvrcfhq… is the Google Health client — confirm it holds http://localhost:52789/callback under Authorized redirect URIs (desktop/iOS loopback only; the Android GIS flow uses no redirect URI) and that its secret matches GOOGLE_HEALTH_CLIENT_SECRET. The other client, 305941286539-8p9itkud4m0r9luiu53rg2s9liqon7lm… ("GLP-1 Companion Web", redirect https://pyrgjdvqbjzakuwvbpxw.supabase.co/auth/v1/callback), is the Supabase sign-in client — leave it untouched. Secrets are no longer viewable in the console — rotate via Add secret + .env update if mismatched.