diff --git a/BUILD_STATE.md b/BUILD_STATE.md index 36d645d..cabced5 100644 --- a/BUILD_STATE.md +++ b/BUILD_STATE.md @@ -3,7 +3,7 @@ ## 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 3C retrofit — Nutritional Search Engine, Barcode Scanning & Dual-Source Food Cache (2026-10-17) +- **Latest Addendum:** Phase 4B — Private Recipe Sources & Mealie Integration (2026-10-17) ## Phase Checklist - [x] Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB) @@ -12,6 +12,7 @@ - [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) @@ -179,4 +180,20 @@ ## 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. \ No newline at end of file +- [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. \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md index f003646..4a98be8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,6 +86,35 @@ Within each feature module in `lib/features//`, separation of conc | **Push Notifications** | `flutter_local_notifications` | Cross-platform offline-scheduled alerts for injections and hydration checks. | --- +## 4. Multiplatform UX/UI Adaptation Engine + +The UI layer implements an **Adaptive Design Strategy** rather than a lowest common denominator approach: + +```text + ┌────────────────────────────────────┐ + │ Platform Theme Director │ + └─────────────────┬──────────────────┘ + │ + ┌───────────────────────┴───────────────────────┐ + ▼ ▼ + [Apple: iOS & macOS] [Material: Android, Web, Win] +┌─────────────────────────────────┐ ┌─────────────────────────────────┐ +│ Cupertino Engine │ │ Material 3 Engine │ +│ • CupertinoNavigationBar │ │ • NavigationRail / NavigationBar│ +│ • CupertinoSliverRefreshControl │ │ • FloatingActionButtons │ +│ • SF Pro System Typography │ │ • Roboto / Adaptive Font Scaler │ +│ • Haptic Feedback & Swipe-Back │ │ • Dynamic Color / ColorScheme │ +└─────────────────────────────────┘ └─────────────────────────────────┘ +``` + +### Breakpoint Matrix + +- **Compact (< 600dp - Mobile):** Bottom `CupertinoTabBar` or `NavigationBar`. Full-screen sheets for dose logging. +- **Medium (600dp - 1024dp - Foldables / Tablets):** Persistent navigation rail, split-view list/detail pane for recipe browsing and pantry audits. +- **Expanded (> 1024dp - Desktop / Web):** Multi-column dashboard, persistent sidebar navigation rail, keyboard shortcuts (e.g., `Cmd/Ctrl + N` for quick food logging), modal popovers rather than full-screen sheets. + +--- + ### 4.7 Phase 3B: Platform Health Integrations & HealthGateway Architecture #### 4.7.1 System Overview @@ -348,34 +377,49 @@ class FoodProductsTable extends Table { 2. **Circuit Timeout:** Remote calls to Open Food Facts enforce a 4-second timeout. Upon timeout or HTTP 5xx error, the client transparently routes the query to `UsdaFdcClient`. 3. **Write-Through Local Caching:** Every successfully resolved remote payload from OFF or USDA FDC is written into `FoodProductsTable` with `insertOnConflictUpdate`. Subsequent lookups for identical barcodes or query tokens resolve locally with zero network I/O. -## 4. Multiplatform UX/UI Adaptation Engine +### 4.9 Phase 4B: Private Recipe Integration (Mealie REST API Client) -The UI layer implements an **Adaptive Design Strategy** rather than a lowest common denominator approach: +#### 4.9.1 Subsystem Overview +The Private Recipe Provider subsystem allows connecting authenticated, self-hosted recipe platforms (initially Mealie v1.x) to ingest recipes into the encrypted local SQLite database. ```text - ┌────────────────────────────────────┐ - │ Platform Theme Director │ - └─────────────────┬──────────────────┘ - │ - ┌───────────────────────┴───────────────────────┐ - ▼ ▼ - [Apple: iOS & macOS] [Material: Android, Web, Win] -┌─────────────────────────────────┐ ┌─────────────────────────────────┐ -│ Cupertino Engine │ │ Material 3 Engine │ -│ • CupertinoNavigationBar │ │ • NavigationRail / NavigationBar│ -│ • CupertinoSliverRefreshControl │ │ • FloatingActionButtons │ -│ • SF Pro System Typography │ │ • Roboto / Adaptive Font Scaler │ -│ • Haptic Feedback & Swipe-Back │ │ • Dynamic Color / ColorScheme │ -└─────────────────────────────────┘ └─────────────────────────────────┘ + ┌────────────────────────────────────────────────────────┐ + │ Settings Screen │ + │ - Base URL: [https://mealie.kellfamilyhome.com](https://mealie.kellfamilyhome.com) │ + │ - API Token: Bearer │ + └───────────────────────────┬────────────────────────────┘ + │ + ▼ + ┌────────────────────────────────────────────────────────┐ + │ MealieApiClient │ + │ - GET /api/recipes (Paginated summary & slug) │ + │ - GET /api/recipes/{slug} (Full ingredients/macros) │ + └───────────────────────────┬────────────────────────────┘ + │ + ▼ + ┌────────────────────────────────────────────────────────┐ + │ Normalized Recipe Domain Entity │ + │ - Nutrition: calories, protein, carbs, fat │ + │ - Source tag: 'mealie' │ + └───────────────────────────┬────────────────────────────┘ + │ + ▼ + ┌────────────────────────────────────────────────────────┐ + │ RecipesTable (Drift SQLCipher) │ + │ Appears natively in /recipes tab │ + └────────────────────────────────────────────────────────┘ ``` -### Breakpoint Matrix +#### 4.9.2 Data Contracts & Mealie Normalization +- **Endpoint Structure:** + * Listing: `GET {baseUrl}/api/recipes?perPage=-1` returns all user recipes. + * Details: `GET {baseUrl}/api/recipes/{slug}` returns full structured steps, ingredient notes, and `nutrition` object (`calories`, `proteinContent`, `carbohydrateContent`, `fatContent`). + * Image Asset: `{baseUrl}/api/media/recipes/{recipeId}/images/min-original.webp` +- **Security:** + * Server Base URL is stored in Drift settings/preferences. + * API Token is persisted exclusively in `FlutterSecureStorage` (Android Keystore / Apple Keychain / Windows DPAPI). -- **Compact (< 600dp - Mobile):** Bottom `CupertinoTabBar` or `NavigationBar`. Full-screen sheets for dose logging. -- **Medium (600dp - 1024dp - Foldables / Tablets):** Persistent navigation rail, split-view list/detail pane for recipe browsing and pantry audits. -- **Expanded (> 1024dp - Desktop / Web):** Multi-column dashboard, persistent sidebar navigation rail, keyboard shortcuts (e.g., `Cmd/Ctrl + N` for quick food logging), modal popovers rather than full-screen sheets. ---- ## 5. Security & Passwordless Authentication Architecture diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart index bfbf86e..aa22273 100644 --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -648,6 +648,13 @@ class AppDatabase extends _$AppDatabase { Future insertRecipe(RecipesTableCompanion entry) => into(recipesTable).insert(entry); + /// Inserts or updates a recipe row (`INSERT … ON CONFLICT(id) DO UPDATE`), + /// the write path for Mealie re-syncs where the primary key is the stable + /// Mealie recipe UUID (only the columns present in [entry] are written, so + /// an absent `createdAt` survives the upsert). + Future upsertRecipe(RecipesTableCompanion entry) => + into(recipesTable).insertOnConflictUpdate(entry); + /// Partially updates the recipe identified by [id] (only non-absent /// companion columns are written, so `createdAt` survives re-imports). Future updateRecipe(String id, RecipesTableCompanion updates) => diff --git a/lib/core/security/crypto_manager.dart b/lib/core/security/crypto_manager.dart index dc29fc9..6f63361 100644 --- a/lib/core/security/crypto_manager.dart +++ b/lib/core/security/crypto_manager.dart @@ -2,8 +2,14 @@ import 'dart:convert'; import 'dart:math'; import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +/// Application-wide secure vault (SQLCipher master key + private integration +/// credentials). Kept in `core` so both the database bootstrap and feature +/// controllers share one instance configuration. +final cryptoManagerProvider = Provider((ref) => CryptoManager()); + /// Platform-hardened vault for the SQLCipher AES-256 master key. /// /// Every target stores the key in its native secret store with safe @@ -30,6 +36,18 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; class CryptoManager { static const _databaseKeyName = 'glp1_sqlcipher_master_key'; + // --------------------------------------------------------------------------- + // Private recipe provider keys (Phase 4B — Mealie self-hosted instance) + // --------------------------------------------------------------------------- + + /// Vault key for the Mealie server base URL (architecture §4.9.2: the + /// pairing never lands in plain `SharedPreferences`). + static const String mealieBaseUrlKey = 'mealie_base_url'; + + /// Vault key for the Mealie API bearer token (Android Keystore / Apple + /// Keychain / Windows DPAPI — see the class documentation table). + static const String mealieApiTokenKey = 'mealie_api_token'; + /// Platform-safe vault configuration (see the class documentation). static const FlutterSecureStorage _platformVault = FlutterSecureStorage( // Android: AES-256 EncryptedSharedPreferences (API 23+). @@ -77,6 +95,48 @@ class CryptoManager { return _ephemeralKey ??= newKey; } + // --------------------------------------------------------------------------- + // Private recipe provider credentials (Phase 4B — Mealie) + // --------------------------------------------------------------------------- + + /// Persists the Mealie pairing (server URL + API bearer token) in the + /// platform vault. Returns `false` when the vault rejected the write so the + /// caller can surface a storage failure instead of pretending to be saved. + Future writeMealieCredentials({ + required String baseUrl, + required String apiToken, + }) async { + final stored = await _tryOrNull(() async { + await _secureStorage.write(key: mealieBaseUrlKey, value: baseUrl); + await _secureStorage.write(key: mealieApiTokenKey, value: apiToken); + return true; + }); + return stored == true; + } + + /// Reads the stored Mealie pairing, or `null` when the integration has not + /// been configured (or the vault is unavailable). + Future<({String baseUrl, String apiToken})?> readMealieCredentials() async { + final values = await _tryOrNull(() async { + final baseUrl = await _secureStorage.read(key: mealieBaseUrlKey); + final apiToken = await _secureStorage.read(key: mealieApiTokenKey); + return (baseUrl: baseUrl, apiToken: apiToken); + }); + final baseUrl = values?.baseUrl?.trim() ?? ''; + final apiToken = values?.apiToken?.trim() ?? ''; + if (baseUrl.isEmpty || apiToken.isEmpty) return null; + return (baseUrl: baseUrl, apiToken: apiToken); + } + + /// Removes the stored Mealie pairing (Settings → Disconnect). Never throws. + Future deleteMealieCredentials() async { + await _tryOrNull(() async { + await _secureStorage.delete(key: mealieBaseUrlKey); + await _secureStorage.delete(key: mealieApiTokenKey); + return true; + }); + } + /// 256-bit cryptographically random key, base64url-encoded for safe /// embedding in `PRAGMA key`. static String _generateKey() { diff --git a/lib/features/meal_planner/data/repositories/drift_recipe_repository.dart b/lib/features/meal_planner/data/repositories/drift_recipe_repository.dart index 330c535..b5427dc 100644 --- a/lib/features/meal_planner/data/repositories/drift_recipe_repository.dart +++ b/lib/features/meal_planner/data/repositories/drift_recipe_repository.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:drift/drift.dart'; import '../../../../core/database/app_database.dart'; +import '../../../recipes/data/datasources/mealie_api_client.dart'; import '../../domain/entities/macro_profile.dart'; import '../../domain/entities/recipe.dart'; import '../../domain/entities/recipe_ingredient.dart'; @@ -58,6 +59,35 @@ class DriftRecipeRepository implements IRecipeRepository { @override Future deleteRecipe(String id) => _database.deleteRecipe(id); + // --------------------------------------------------------------------------- + // Private recipe source sync (Phase 4B — Mealie) + // --------------------------------------------------------------------------- + + /// Pulls every recipe from a self-hosted Mealie server through + /// [MealieApiClient.fetchAllRecipes] and upserts the normalized entities + /// into `RecipesTable` (`insertOnConflictUpdate` keyed on the Mealie UUID, + /// so re-syncs refresh rows in place). The original `createdAt` is preserved + /// so the catalog's newest-first ordering never shifts on re-sync. + /// + /// Returns the number of recipes written. + Future syncFromMealie( + String baseUrl, + String token, { + MealieApiClient? client, + }) async { + final api = client ?? MealieApiClient(); + final recipes = await api.fetchAllRecipes(baseUrl, token); + var synced = 0; + for (final recipe in recipes) { + final existing = await _database.getRecipeById(recipe.id); + await _database.upsertRecipe( + _toCompanion(recipe, existingCreatedAt: existing?.createdAt), + ); + synced++; + } + return synced; + } + // --------------------------------------------------------------------------- // Mapping // --------------------------------------------------------------------------- diff --git a/lib/features/recipes/data/datasources/mealie_api_client.dart b/lib/features/recipes/data/datasources/mealie_api_client.dart new file mode 100644 index 0000000..6de5f4f --- /dev/null +++ b/lib/features/recipes/data/datasources/mealie_api_client.dart @@ -0,0 +1,382 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../../../../core/utils/uuid.dart'; +import '../../../meal_planner/domain/entities/macro_profile.dart'; +import '../../../meal_planner/domain/entities/recipe.dart'; +import '../../../meal_planner/domain/entities/recipe_ingredient.dart'; + +/// Thin REST client for a self-hosted Mealie v1.x server (Phase 4B: private +/// recipe sources — architecture §4.9). +/// +/// Every call authenticates with `Authorization: Bearer `; the +/// token is passed in per call (read from `CryptoManager`'s platform vault by +/// the caller) so the client itself never retains credentials. +/// +/// Normalization contract (docs/architecture.md §4.9.2): +/// * listing — `GET {base}/api/recipes?perPage=-1` returns every summary; +/// * details — `GET {base}/api/recipes/{slug}` adds `recipeIngredient`, +/// `recipeInstructions`, and the `nutrition` object; +/// * image — `{base}/api/media/recipes/{id}/images/min-original.webp`; +/// * source — `{base}/recipe/{slug}`, also used as the stable primary key: +/// the Mealie recipe UUID becomes `Recipe.id`, so re-syncs are idempotent +/// `insertOnConflictUpdate` upserts. +class MealieApiClient { + MealieApiClient({http.Client? client}) : _client = client ?? http.Client(); + + final http.Client _client; + + /// Per-request network budget: a healthy LAN/instance answers well under + /// this; a timeout surfaces as a failed connection test instead of a hung + /// dialog. + static const Duration requestTimeout = Duration(seconds: 15); + + /// Default instance offered by the connect dialog hint. + static const String defaultBaseUrl = 'https://mealie.kellfamilyhome.com'; + + /// Trims whitespace and trailing slashes so URL joins never double up. + static String normalizeBaseUrl(String baseUrl) { + var trimmed = baseUrl.trim(); + while (trimmed.endsWith('/')) { + trimmed = trimmed.substring(0, trimmed.length - 1); + } + return trimmed; + } + + /// Whether [sourceUrl] points at the Mealie web route `{base}/recipe/…` of + /// [baseUrl] — used to badge/filter privately synced recipes. A host-based + /// fallback keeps already-synced rows recognized after scheme/port drift. + static bool isMealieSourceUrl(String sourceUrl, String baseUrl) { + final base = normalizeBaseUrl(baseUrl); + if (base.isEmpty) return false; + final url = sourceUrl.trim(); + if (url.startsWith('$base/recipe/')) return true; + try { + final source = Uri.parse(url); + final server = Uri.parse(base); + return source.host.isNotEmpty && + source.host == server.host && + source.path.startsWith('/recipe/'); + } on FormatException { + return false; + } + } + + /// Verifies [baseUrl] + [token] against the instance and returns `true` + /// only for HTTP 200. Probes the cheap `/api/app/about` endpoint first and + /// falls back to a one-row recipe listing (older builds gate `/about` + /// behind wider scopes). + Future testConnection(String baseUrl, String token) async { + final base = normalizeBaseUrl(baseUrl); + final trimmedToken = token.trim(); + if (base.isEmpty || trimmedToken.isEmpty) return false; + + const probes = ['/api/app/about', '/api/recipes?perPage=1']; + for (final path in probes) { + try { + final response = await _client + .get(Uri.parse('$base$path'), headers: _headers(trimmedToken)) + .timeout(requestTimeout); + if (response.statusCode == 200) return true; + } catch (_) { + // Network/parse failure — fall through to the next probe. + } + } + return false; + } + + /// Downloads the full recipe catalog and maps every entry onto the domain + /// [Recipe] entity: + /// + /// 1. `GET {base}/api/recipes?perPage=-1` lists every recipe summary. + /// 2. Each summary is enriched with `GET {base}/api/recipes/{slug}` for + /// `recipeIngredient`, `recipeInstructions`, and `nutrition`; a failed + /// detail fetch degrades to the summary-only payload instead of + /// aborting the whole sync. + /// 3. Image and source URLs are constructed per the §4.9.2 contract. + /// + /// Throws [MealieApiException] when the listing itself is rejected. + Future> fetchAllRecipes(String baseUrl, String token) async { + final base = normalizeBaseUrl(baseUrl); + final response = await _client + .get(Uri.parse('$base/api/recipes?perPage=-1'), headers: _headers(token)) + .timeout(requestTimeout); + if (response.statusCode != 200) { + throw MealieApiException( + 'Mealie responded with HTTP ${response.statusCode} while listing ' + 'recipes.', + ); + } + + final recipes = []; + for (final summary in _decodeList(response.body)) { + final payload = await _fetchDetails(base, token, summary); + final recipe = mapRecipe(payload, baseUrl: base); + if (recipe != null) recipes.add(recipe); + } + return recipes; + } + + /// Maps a Mealie recipe payload (summary merged with detail fields) onto + /// the domain [Recipe]; returns `null` when no usable title exists. + /// + /// Handles both Mealie payload shapes: structured objects (ingredients as + /// `{quantity, unit, food, note}` maps, instructions as `{name, text}` + /// sections) and legacy plain-string entries. + static Recipe? mapRecipe( + Map payload, { + required String baseUrl, + }) { + final base = normalizeBaseUrl(baseUrl); + final title = payload['name']?.toString().trim() ?? ''; + if (title.isEmpty) return null; + + // The Mealie recipe UUID doubles as the local primary key so repeated + // syncs upsert the same row; fall back to a generated UUID only when the + // server omits `id`. + final mealieId = payload['id']?.toString().trim() ?? ''; + final id = mealieId.isEmpty ? generateUuid() : mealieId; + final slug = payload['slug']?.toString().trim() ?? ''; + + final description = payload['description']?.toString().trim() ?? ''; + return Recipe( + id: id, + title: title, + sourceUrl: slug.isEmpty ? '$base/recipe/$id' : '$base/recipe/$slug', + imageUrl: '$base/api/media/recipes/$id/images/min-original.webp', + description: description.isEmpty ? null : description, + prepTimeMinutes: _parseDurationMinutes(payload['prepTime']), + cookTimeMinutes: _parseDurationMinutes(payload['cookTime']), + servings: _parseServings(payload), + ingredients: _parseIngredients(payload['recipeIngredient']), + instructions: _parseInstructions(payload['recipeInstructions']), + macrosPerServing: _parseNutrition(payload['nutrition']), + ); + } + + // --------------------------------------------------------------------------- + // Networking + // --------------------------------------------------------------------------- + + static Map _headers(String token) => { + 'Authorization': 'Bearer ${token.trim()}', + 'Accept': 'application/json', + 'User-Agent': 'GLP-1 Companion/0.1.0 (private recipe sync)', + }; + + /// Merges the listing summary with the detail payload for [slug]; falls + /// back to the summary alone when the slug is missing or the detail call + /// fails (partial sync beats no sync). + Future> _fetchDetails( + String base, + String token, + Map summary, + ) async { + final slug = summary['slug']?.toString().trim() ?? ''; + if (slug.isEmpty) return summary; + try { + final response = await _client + .get( + Uri.parse('$base/api/recipes/${Uri.encodeComponent(slug)}'), + headers: _headers(token), + ) + .timeout(requestTimeout); + if (response.statusCode != 200) return summary; + final decoded = jsonDecode(response.body); + if (decoded is! Map) return summary; + return { + ...summary, + ...Map.from(decoded), + }; + } catch (_) { + return summary; + } + } + + /// Accepts both response shapes of `GET /api/recipes`: a bare JSON array + /// (`perPage=-1`) and a paginated `{items: []}` envelope. + static List> _decodeList(String body) { + final decoded = jsonDecode(body); + final List raw; + if (decoded is List) { + raw = decoded; + } else if (decoded is Map && decoded['items'] is List) { + raw = decoded['items'] as List; + } else { + raw = const []; + } + return >[ + for (final entry in raw) + if (entry is Map) Map.from(entry), + ]; + } + + // --------------------------------------------------------------------------- + // Payload → domain normalization + // --------------------------------------------------------------------------- + + /// Mealie durations are ISO-8601 (`PT30M`, `P1DT2H`); plain minute counts + /// are accepted as a lenient fallback. Unparsable values map to `0`, the + /// domain's "unknown" convention. + static int _parseDurationMinutes(Object? value) { + if (value is num) return value.round(); + if (value is! String) return 0; + final trimmed = value.trim(); + if (trimmed.isEmpty) return 0; + if (RegExp(r'^\d+$').hasMatch(trimmed)) return int.parse(trimmed); + + final match = RegExp( + r'^P(?:(\d+(?:\.\d+)?)D)?' + r'(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$', + caseSensitive: false, + ).firstMatch(trimmed); + if (match == null) return 0; + + double group(int index) => double.tryParse(match.group(index) ?? '') ?? 0; + final totalMinutes = + group(1) * 1440 + group(2) * 60 + group(3) + group(4) / 60; + return totalMinutes.round(); + } + + /// Yield from `recipeYieldQuantity` when present, else the first integer in + /// the human `recipeYield` string (`"6 servings"` → 6), else 1. + static int _parseServings(Map payload) { + final quantity = payload['recipeYieldQuantity']; + if (quantity is num && quantity > 0) return quantity.round(); + + final yieldValue = payload['recipeYield']; + if (yieldValue is num && yieldValue > 0) return yieldValue.round(); + if (yieldValue is String) { + final match = RegExp(r'\d+').firstMatch(yieldValue); + if (match != null) return int.parse(match.group(0)!); + } + return 1; + } + + /// Ingredients arrive as structured objects (Mealie ≥1.x) or plain strings + /// (legacy exports). Objects keep their exact quantity/unit fields; note + /// text rides after a comma (`"2 cup ricotta, whole milk"`). + static List _parseIngredients(Object? raw) { + if (raw is! List) return const []; + final ingredients = []; + for (final item in raw) { + if (item is String) { + final line = item.trim(); + if (line.isNotEmpty) ingredients.add(RecipeIngredient.parse(line)); + continue; + } + if (item is! Map) continue; + + final food = item['food']?.toString().trim() ?? ''; + final note = item['note']?.toString().trim() ?? ''; + final quantity = (item['quantity'] as num?)?.toDouble(); + final unit = _unitName(item['unit'])?.trim().toLowerCase(); + final normalizedUnit = (unit == null || unit.isEmpty) ? null : unit; + if (food.isEmpty && note.isEmpty && quantity == null) continue; + + final buffer = StringBuffer(); + if (quantity != null) buffer.write('${_trimDouble(quantity)} '); + if (normalizedUnit != null) buffer.write('$normalizedUnit '); + buffer.write(food); + if (note.isNotEmpty) { + if (food.isNotEmpty) buffer.write(', '); + buffer.write(note); + } + + ingredients.add( + RecipeIngredient( + rawText: buffer.toString().trim(), + parsedItemName: + note.isEmpty ? food : (food.isEmpty ? note : '$food, $note'), + quantity: quantity, + unit: normalizedUnit, + ), + ); + } + return ingredients; + } + + /// Instructions arrive as step sections (`{name, text}`) or plain strings; + /// sections flatten to their text (or name when the text is empty), keeping + /// the server's ordering. + static List _parseInstructions(Object? raw) { + if (raw is! List) return const []; + final steps = []; + for (final item in raw) { + if (item is String) { + final text = item.trim(); + if (text.isNotEmpty) steps.add(text); + continue; + } + if (item is! Map) continue; + final text = item['text']?.toString().trim() ?? ''; + if (text.isNotEmpty) { + steps.add(text); + continue; + } + final name = item['name']?.toString().trim() ?? ''; + if (name.isNotEmpty) steps.add(name); + } + return steps; + } + + /// Nutrition values mix numbers and display strings (`"512 kcal"`, + /// `"30.5 g"`); both parse through the same first-number extraction. + static MacroProfile _parseNutrition(Object? raw) { + if (raw is! Map) return const MacroProfile(); + return MacroProfile( + calories: _parseNumber(raw['calories']).round(), + proteinGrams: _parseNumber(raw['proteinContent']), + carbsGrams: _parseNumber(raw['carbohydrateContent']), + fatGrams: _parseNumber(raw['fatContent']), + ); + } + + static double _parseNumber(Object? value) { + if (value is num) return value.toDouble(); + if (value is String) { + final match = + RegExp(r'-?\d+(?:\.\d+)?').firstMatch(value.replaceAll(',', '')); + if (match != null) return double.tryParse(match.group(0)!) ?? 0; + } + return 0; + } + + /// Mealie units may be a bare string (`"cup"`) or a unit object with a + /// `name` / `abbreviation` (Mealie group units). + static String? _unitName(Object? unit) { + if (unit is String) return unit; + if (unit is Map) { + final name = unit['name']?.toString().trim(); + if (name != null && name.isNotEmpty) return name; + final abbreviation = unit['abbreviation']?.toString().trim(); + if (abbreviation != null && abbreviation.isNotEmpty) { + return abbreviation; + } + } + return null; + } + + /// Renders `2.0` as `"2"` so raw ingredient text matches what humans read. + static String _trimDouble(double value) { + final rounded = value.roundToDouble(); + return rounded == value ? rounded.toStringAsFixed(0) : value.toString(); + } + + /// Releases the underlying HTTP client. + void close() => _client.close(); +} + +/// Non-fatal failure surfaced by [MealieApiClient]; callers degrade to an +/// inline error message (connection test / sync status). +class MealieApiException implements Exception { + const MealieApiException(this.message); + + final String message; + + @override + String toString() => 'MealieApiException($message)'; +} + + diff --git a/lib/features/recipes/presentation/controllers/mealie_integration_controller.dart b/lib/features/recipes/presentation/controllers/mealie_integration_controller.dart new file mode 100644 index 0000000..9f3df40 --- /dev/null +++ b/lib/features/recipes/presentation/controllers/mealie_integration_controller.dart @@ -0,0 +1,221 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/security/crypto_manager.dart'; +import '../../../meal_planner/data/repositories/drift_recipe_repository.dart'; +import '../../../meal_planner/domain/entities/recipe.dart'; +import '../../../meal_planner/presentation/controllers/recipe_import_controller.dart'; +import '../../data/datasources/mealie_api_client.dart'; + +/// Mealie REST client used by the connection test and catalog sync. +final mealieApiClientProvider = Provider( + (ref) => MealieApiClient(), +); + +/// Drift-backed recipe repository when the live catalog is database-backed; +/// `null` when a test overrides [recipeRepositoryProvider] with an in-memory +/// fake (the Sync affordances then report that sync is unavailable). +final mealieSyncRepositoryProvider = Provider((ref) { + final repository = ref.watch(recipeRepositoryProvider); + return repository is DriftRecipeRepository ? repository : null; +}); + +/// Immutable snapshot of the private recipe (Mealie) integration: whether +/// credentials are vaulted, the active endpoint, and sync bookkeeping. +class MealieIntegrationState { + const MealieIntegrationState({ + this.isLoading = false, + this.isConfigured = false, + this.baseUrl, + this.isSyncing = false, + this.errorMessage, + this.statusMessage, + }); + + /// Sentinel allowing `copyWith` to explicitly reset a field to `null`. + static const Object _keep = Object(); + + /// `true` while the vaulted pairing is being read on startup. + final bool isLoading; + + /// `true` when a base URL + API token pair is stored in the platform vault. + final bool isConfigured; + + /// Active (normalized) Mealie server URL, when configured. + final String? baseUrl; + + /// `true` while a catalog sync round-trip is in flight. + final bool isSyncing; + + /// Last failure (sync / vault), surfaced inline as a snackbar. + final String? errorMessage; + + /// Last successful action message, surfaced as a snackbar. + final String? statusMessage; + + /// Whether [recipe] was synced from the configured Mealie instance — + /// drives the vault badge and the [RecipeSourceFilter.mealie] filter. + bool isMealieRecipe(Recipe recipe) { + final base = baseUrl; + if (base == null || base.isEmpty) return false; + return MealieApiClient.isMealieSourceUrl(recipe.sourceUrl, base); + } + + MealieIntegrationState copyWith({ + bool? isLoading, + bool? isConfigured, + Object? baseUrl = _keep, + bool? isSyncing, + Object? errorMessage = _keep, + Object? statusMessage = _keep, + }) => + MealieIntegrationState( + isLoading: isLoading ?? this.isLoading, + isConfigured: isConfigured ?? this.isConfigured, + baseUrl: identical(baseUrl, _keep) ? this.baseUrl : baseUrl as String?, + isSyncing: isSyncing ?? this.isSyncing, + errorMessage: identical(errorMessage, _keep) + ? this.errorMessage + : errorMessage as String?, + statusMessage: identical(statusMessage, _keep) + ? this.statusMessage + : statusMessage as String?, + ); +} + +/// Orchestrates the Phase 4B private recipe source: loads the vaulted Mealie +/// pairing, verifies connectivity before persisting it ("Test & Save"), +/// runs catalog syncs against the drift repository, and tears the pairing +/// down on disconnect. The API token never enters state — it is read from +/// the platform vault only for the duration of a call. +class MealieIntegrationController extends Notifier { + /// Disposal sentinel (Riverpod 2.x `Ref` has no `mounted` getter), so async + /// gaps guard on this flag instead of touching `state` after teardown. + var _disposed = false; + + @override + MealieIntegrationState build() { + ref.onDispose(() => _disposed = true); + Future.microtask(_loadCredentials); + return const MealieIntegrationState(isLoading: true); + } + + /// Reads the vaulted pairing; a missing pair (or unavailable vault on + /// Web/headless CI) simply renders the "Disconnected" state. + Future _loadCredentials() async { + final credentials = await ref.read(cryptoManagerProvider).readMealieCredentials(); + if (_disposed) return; + if (credentials == null) { + state = state.copyWith(isLoading: false); + return; + } + state = MealieIntegrationState( + isLoading: false, + isConfigured: true, + baseUrl: credentials.baseUrl, + ); + } + + /// Verifies connectivity against the instance and, only on success, vaults + /// the pairing. Returns `null` on success or a user-facing error message + /// (rendered inline by the connect dialog). + Future testAndSave({ + required String baseUrl, + required String token, + }) async { + final normalized = MealieApiClient.normalizeBaseUrl(baseUrl); + final trimmedToken = token.trim(); + final uri = Uri.tryParse(normalized); + if (normalized.isEmpty || uri == null || !uri.hasScheme || uri.host.isEmpty) { + return 'Enter the full server URL, e.g. ${MealieApiClient.defaultBaseUrl}.'; + } + if (trimmedToken.isEmpty) return 'Paste an API bearer token first.'; + + final reachable = await ref + .read(mealieApiClientProvider) + .testConnection(normalized, trimmedToken); + if (_disposed) return null; + if (!reachable) { + return 'Could not verify the connection. Check the server URL and the ' + 'API bearer token, then try again.'; + } + + final stored = await ref.read(cryptoManagerProvider).writeMealieCredentials( + baseUrl: normalized, + apiToken: trimmedToken, + ); + if (_disposed) return null; + if (!stored) { + return 'The connection worked, but the platform keystore refused to ' + 'save the token. On Web, use a native app for stored credentials.'; + } + + state = MealieIntegrationState( + isConfigured: true, + baseUrl: normalized, + statusMessage: 'Connected to Mealie at $normalized.', + ); + return null; + } + + /// Removes the vaulted pairing and resets to the disconnected state. + Future disconnect() async { + await ref.read(cryptoManagerProvider).deleteMealieCredentials(); + if (_disposed) return; + state = const MealieIntegrationState( + statusMessage: 'Mealie connection removed.', + ); + } + + /// Runs a full catalog sync (vaulted pairing → drift upsert) and records + /// the outcome for the caller's snackbar. + Future syncNow() async { + final repository = ref.read(mealieSyncRepositoryProvider); + final credentials = + await ref.read(cryptoManagerProvider).readMealieCredentials(); + if (_disposed) return; + if (repository == null || credentials == null) { + state = state.copyWith( + errorMessage: 'Mealie sync is unavailable — reconnect the server ' + 'in Settings first.', + ); + return; + } + + state = state.copyWith(isSyncing: true, errorMessage: null, statusMessage: null); + try { + final synced = await repository.syncFromMealie( + credentials.baseUrl, + credentials.apiToken, + client: ref.read(mealieApiClientProvider), + ); + if (_disposed) return; + state = state.copyWith( + isSyncing: false, + statusMessage: synced == 0 + ? 'Mealie returned no recipes.' + : 'Synced $synced recipe${synced == 1 ? '' : 's'} from Mealie.', + ); + } catch (error, stackTrace) { + debugPrintStack(stackTrace: stackTrace, label: 'Mealie sync failed'); + if (_disposed) return; + state = state.copyWith( + isSyncing: false, + errorMessage: 'Mealie sync failed: $error', + ); + } + } + + /// Clears the transient status/error banners. + void clearMessages() { + if (state.errorMessage != null || state.statusMessage != null) { + state = state.copyWith(errorMessage: null, statusMessage: null); + } + } +} + +/// Exposes the [MealieIntegrationController] to the widget tree. +final mealieIntegrationControllerProvider = + NotifierProvider( + MealieIntegrationController.new, +); diff --git a/lib/features/recipes/presentation/screens/recipes_screen.dart b/lib/features/recipes/presentation/screens/recipes_screen.dart index abe0339..0b622a9 100644 --- a/lib/features/recipes/presentation/screens/recipes_screen.dart +++ b/lib/features/recipes/presentation/screens/recipes_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../meal_planner/domain/entities/recipe.dart'; import '../../../meal_planner/presentation/controllers/recipe_import_controller.dart'; import '../../../meal_planner/presentation/widgets/recipe_preview_dialog.dart'; +import '../controllers/mealie_integration_controller.dart'; /// One selectable macro filter chip applied to the recipe vault grid. enum MacroFilter { @@ -33,7 +34,22 @@ enum MacroFilter { } } -/// Dedicated Recipe Vault: URL import bar, search + macro filter chips, and +/// One selectable recipe-source filter chip applied to the recipe vault +/// grid (Phase 4B): separates public web scrapes from recipes synced from +/// the private Mealie instance. +enum RecipeSourceFilter { + all('All'), + webScraped('Web Scraped'), + mealie('Mealie'); + + const RecipeSourceFilter(this.label); + + /// Human-readable chip label. + final String label; +} + +/// Dedicated Recipe Vault: URL import bar, search + source/macro filter +/// chips, and /// a responsive virtualized grid of recipe cards with chunked infinite /// scroll (cards are built lazily by [GridView.builder]; the visible window /// extends as the user approaches the end of the loaded chunk). @@ -57,6 +73,7 @@ class _RecipesScreenState extends ConsumerState { String _query = ''; MacroFilter _activeFilter = MacroFilter.all; + RecipeSourceFilter _activeSource = RecipeSourceFilter.all; /// Number of filtered recipes currently materialized in the grid; grows /// in [_pageSize] chunks to emulate infinite scroll over the catalog. @@ -72,10 +89,14 @@ class _RecipesScreenState extends ConsumerState { super.dispose(); } - List _applyFilters(List recipes) { + List _applyFilters( + List recipes, + MealieIntegrationState mealie, + ) { final query = _query.trim().toLowerCase(); return recipes.where((recipe) { if (!_activeFilter.accepts(recipe)) return false; + if (!_sourceAccepts(recipe, mealie)) return false; if (query.isEmpty) return true; return recipe.title.toLowerCase().contains(query) || (recipe.description ?? '').toLowerCase().contains(query) || @@ -83,6 +104,21 @@ class _RecipesScreenState extends ConsumerState { }).toList(growable: false); } + /// Applies the [RecipeSourceFilter] chip: Mealie recipes are recognized + /// through their `{baseUrl}/recipe/{slug}` source URL (see + /// [MealieApiClient.isMealieSourceUrl]); with no server configured every + /// recipe counts as web-scraped. + bool _sourceAccepts(Recipe recipe, MealieIntegrationState mealie) { + switch (_activeSource) { + case RecipeSourceFilter.all: + return true; + case RecipeSourceFilter.mealie: + return mealie.isMealieRecipe(recipe); + case RecipeSourceFilter.webScraped: + return !mealie.isMealieRecipe(recipe); + } + } + void _extendWindow(int filteredLength) { if (_visibleCount >= filteredLength) return; // Defer past the current build phase: itemBuilder may run mid-build. @@ -97,6 +133,7 @@ class _RecipesScreenState extends ConsumerState { @override Widget build(BuildContext context) { final importState = ref.watch(recipeImportControllerProvider); + final mealieState = ref.watch(mealieIntegrationControllerProvider); ref.listen(recipeImportControllerProvider, (previous, next) { if (next.previewRecipe != null && @@ -116,10 +153,29 @@ class _RecipesScreenState extends ConsumerState { } }); - final filtered = _applyFilters(importState.savedRecipes); + final filtered = _applyFilters(importState.savedRecipes, mealieState); return Scaffold( - appBar: AppBar(title: const Text('Recipe Vault')), + appBar: AppBar( + title: const Text('Recipe Vault'), + actions: [ + if (mealieState.isConfigured) + mealieState.isSyncing + ? const Padding( + padding: EdgeInsets.all(14), + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : IconButton( + tooltip: 'Sync Private Recipes', + icon: const Icon(Icons.cloud_sync_outlined), + onPressed: _syncMealie, + ), + ], + ), body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -147,6 +203,27 @@ class _RecipesScreenState extends ConsumerState { ), Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Wrap( + spacing: 8, + runSpacing: 4, + children: [ + for (final source in RecipeSourceFilter.values) + ChoiceChip( + avatar: source == RecipeSourceFilter.mealie + ? const Icon(Icons.restaurant_menu, size: 16) + : null, + label: Text(source.label), + selected: _activeSource == source, + onSelected: (_) => setState(() { + _activeSource = source; + _visibleCount = _pageSize; + }), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 4), child: Wrap( spacing: 8, runSpacing: 4, @@ -168,7 +245,7 @@ class _RecipesScreenState extends ConsumerState { ? _EmptyVaultHint( hasAnyRecipes: importState.savedRecipes.isNotEmpty, ) - : _buildGrid(filtered), + : _buildGrid(filtered, mealieState), ), ], ), @@ -216,7 +293,7 @@ class _RecipesScreenState extends ConsumerState { /// Responsive virtualized grid: column count adapts to the available /// width and only the first [_visibleCount] filtered recipes are /// materialized, extending in chunks as the user scrolls. - Widget _buildGrid(List filtered) { + Widget _buildGrid(List filtered, MealieIntegrationState mealie) { final materialized = filtered.length < _visibleCount ? filtered.length : _visibleCount; @@ -235,15 +312,37 @@ class _RecipesScreenState extends ConsumerState { if (index >= materialized - _prefetchMargin) { _extendWindow(filtered.length); } + final recipe = filtered[index]; return _RecipeVaultCard( - recipe: filtered[index], - onTap: () => _showDetails(filtered[index]), - onDelete: () => _deleteRecipe(filtered[index]), + recipe: recipe, + isMealie: mealie.isMealieRecipe(recipe), + onTap: () => _showDetails(recipe), + onDelete: () => _deleteRecipe(recipe), ); }, ); } + /// Manual "Sync Private Recipes" round trip; the outcome surfaces as a + /// snackbar and the transient banners are cleared afterwards. + Future _syncMealie() async { + final notifier = ref.read(mealieIntegrationControllerProvider.notifier); + await notifier.syncNow(); + if (!mounted) return; + final state = ref.read(mealieIntegrationControllerProvider); + final message = state.errorMessage ?? state.statusMessage; + if (message == null) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: state.errorMessage != null + ? Theme.of(context).colorScheme.error + : null, + ), + ); + notifier.clearMessages(); + } + Future _submitUrl() async { final url = _urlController.text; await ref.read(recipeImportControllerProvider.notifier).submitUrl(url); @@ -313,12 +412,17 @@ class _RecipeVaultCard extends StatelessWidget { required this.recipe, required this.onTap, required this.onDelete, + this.isMealie = false, }); final Recipe recipe; final VoidCallback onTap; final VoidCallback onDelete; + /// `true` when the recipe was synced from the private Mealie server — + /// renders the subtle cloud badge next to the title. + final bool isMealie; + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -346,12 +450,29 @@ class _RecipeVaultCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - recipe.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium - ?.copyWith(fontWeight: FontWeight.w600), + Row( + children: [ + if (isMealie) ...[ + Tooltip( + message: 'Synced from your Mealie server', + child: Icon( + Icons.restaurant_menu, + size: 14, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: 4), + ], + Expanded( + child: Text( + recipe.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + ), + ], ), const SizedBox(height: 6), Row( diff --git a/lib/features/settings/presentation/screens/settings_screen.dart b/lib/features/settings/presentation/screens/settings_screen.dart index dc112ed..6f85ba2 100644 --- a/lib/features/settings/presentation/screens/settings_screen.dart +++ b/lib/features/settings/presentation/screens/settings_screen.dart @@ -7,6 +7,8 @@ import '../../../../core/network/supabase_status.dart'; import '../../../../core/platform/health_gateway.dart'; import '../../../auth/presentation/controllers/auth_controller.dart'; import '../../../nutrition/presentation/controllers/nutrition_controller.dart'; +import '../../../recipes/data/datasources/mealie_api_client.dart'; +import '../../../recipes/presentation/controllers/mealie_integration_controller.dart'; import '../controllers/integrations_controller.dart'; /// Application settings hub: identity & session, daily tracking targets, @@ -31,6 +33,24 @@ class SettingsScreen extends ConsumerWidget { } }); + ref.listen(mealieIntegrationControllerProvider, (previous, next) { + final messenger = ScaffoldMessenger.of(context); + if (next.errorMessage != null && + next.errorMessage != previous?.errorMessage) { + messenger.showSnackBar( + SnackBar( + content: Text(next.errorMessage!), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + ref.read(mealieIntegrationControllerProvider.notifier).clearMessages(); + } else if (next.statusMessage != null && + next.statusMessage != previous?.statusMessage) { + messenger.showSnackBar(SnackBar(content: Text(next.statusMessage!))); + ref.read(mealieIntegrationControllerProvider.notifier).clearMessages(); + } + }); + return Scaffold( appBar: AppBar(title: const Text('Settings')), body: ListView( @@ -43,6 +63,8 @@ class SettingsScreen extends ConsumerWidget { const _SecuritySection(), const SizedBox(height: 16), _IntegrationsSection(state: integrations), + const SizedBox(height: 16), + const _PrivateRecipeIntegrationsSection(), ], ), ); @@ -507,6 +529,233 @@ class _IntegrationRow extends StatelessWidget { } } +/// Private Recipe Integrations (Phase 4B): a Mealie self-hosted instance +/// card with a Connected / Disconnected badge, the connect modal (URL + +/// obscured API token verified before persistence), and Sync Now / Disconnect +/// actions while paired. +class _PrivateRecipeIntegrationsSection extends ConsumerWidget { + const _PrivateRecipeIntegrationsSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final mealie = ref.watch(mealieIntegrationControllerProvider); + final controller = ref.read(mealieIntegrationControllerProvider.notifier); + + return _SettingsCard( + title: 'Private Recipe Integrations', + icon: Icons.restaurant_menu, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.cloud_outlined, size: 20, color: theme.colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + 'Mealie Self-Hosted Instance', + style: theme.textTheme.titleSmall, + ), + ), + const SizedBox(width: 8), + Flexible( + child: _StatusBadge( + label: + mealie.isConfigured ? 'Connected' : 'Disconnected', + ok: mealie.isConfigured, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + mealie.isConfigured + ? 'Endpoint: ${mealie.baseUrl}' + : 'Sync recipes from your own Mealie server into ' + 'the Recipe Vault.', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.outline), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: mealie.isConfigured + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + mealie.isSyncing + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : OutlinedButton.icon( + onPressed: controller.syncNow, + icon: const Icon(Icons.sync, size: 18), + label: const Text('Sync Now'), + ), + const SizedBox(width: 8), + OutlinedButton( + onPressed: + mealie.isSyncing ? null : controller.disconnect, + child: const Text('Disconnect'), + ), + ], + ) + : FilledButton.tonalIcon( + onPressed: mealie.isLoading + ? null + : () => _openConnectDialog(context), + icon: const Icon(Icons.add_link), + label: const Text('Connect Mealie'), + ), + ), + ], + ), + ); + } + + /// Opens the pairing modal; success is announced by the screen-level + /// status listener when the state flips to connected. + Future _openConnectDialog(BuildContext context) async { + await showDialog( + context: context, + builder: (_) => const _MealieConnectDialog(), + ); + } +} + +/// Modal dialog that pairs the app with a Mealie instance: server URL plus +/// an obscured API bearer token, verified via "Test & Save" before the +/// credentials are written to the platform vault. +class _MealieConnectDialog extends ConsumerStatefulWidget { + const _MealieConnectDialog(); + + @override + ConsumerState<_MealieConnectDialog> createState() => + _MealieConnectDialogState(); +} + +class _MealieConnectDialogState extends ConsumerState<_MealieConnectDialog> { + final TextEditingController _urlController = TextEditingController( + text: MealieApiClient.defaultBaseUrl, + ); + final TextEditingController _tokenController = TextEditingController(); + + bool _testing = false; + String? _errorText; + + @override + void dispose() { + _urlController.dispose(); + _tokenController.dispose(); + super.dispose(); + } + + /// Runs the connectivity check; only a verified pairing is vaulted, and + /// failures render inline instead of closing the dialog. + Future _testAndSave() async { + setState(() { + _testing = true; + _errorText = null; + }); + final error = await ref + .read(mealieIntegrationControllerProvider.notifier) + .testAndSave(baseUrl: _urlController.text, token: _tokenController.text); + if (!mounted) return; + if (error != null) { + setState(() { + _testing = false; + _errorText = error; + }); + return; + } + Navigator.of(context).pop(true); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AlertDialog( + title: const Text('Connect Mealie'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _urlController, + enabled: !_testing, + keyboardType: TextInputType.url, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Server URL', + hintText: MealieApiClient.defaultBaseUrl, + border: OutlineInputBorder(), + isDense: true, + prefixIcon: Icon(Icons.dns_outlined), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _tokenController, + enabled: !_testing, + obscureText: true, + decoration: const InputDecoration( + labelText: 'API Bearer Token', + hintText: 'Generate in Mealie under User Profile -> API Tokens', + border: OutlineInputBorder(), + isDense: true, + prefixIcon: Icon(Icons.key_outlined), + helperMaxLines: 2, + helperText: 'Stored only in your platform keystore ' + '(Keychain / Keystore / DPAPI).', + ), + ), + if (_errorText != null) ...[ + const SizedBox(height: 8), + Text( + _errorText!, + style: TextStyle(color: theme.colorScheme.error, fontSize: 12), + ), + ], + ], + ), + actions: [ + TextButton( + onPressed: _testing ? null : () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton.icon( + onPressed: _testing ? null : _testAndSave, + icon: _testing + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.cloud_done_outlined), + label: const Text('Test & Save'), + ), + ], + ); + } +} + /// Shared card chrome for a settings section. class _SettingsCard extends StatelessWidget { const _SettingsCard({ @@ -532,10 +781,16 @@ class _SettingsCard extends StatelessWidget { children: [ Icon(icon, size: 20, color: theme.colorScheme.primary), const SizedBox(width: 8), - Text( - title, - style: theme.textTheme.titleSmall - ?.copyWith(fontWeight: FontWeight.bold), + // Expanded: the title must wrap (or shrink) instead of + // forcing the row past the card width on narrow/mobile + // layouts — long section names (e.g. "Private Recipe + // Integrations") render wider than the 318 dp content box. + Expanded( + child: Text( + title, + style: theme.textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.bold), + ), ), ], ), diff --git a/test/features/recipes/mealie_parser_test.dart b/test/features/recipes/mealie_parser_test.dart new file mode 100644 index 0000000..25d417e --- /dev/null +++ b/test/features/recipes/mealie_parser_test.dart @@ -0,0 +1,383 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:glp1_companion/features/meal_planner/domain/entities/recipe_ingredient.dart'; +import 'package:glp1_companion/features/recipes/data/datasources/mealie_api_client.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +/// Phase 4B: a Mealie v1 recipe payload (ingredients, directions, nutrition) +/// must normalize into the domain [Recipe] entity — identity, media, times, +/// yield, structured ingredient maps, flattened step sections, and the +/// nutrition display strings Mealie serves (`"512 kcal"`, `"30.5 g"`). + +const String _baseUrl = 'https://mealie.kellfamilyhome.com'; +const String _recipeId = '6c1f2a44-9c4a-4d6e-8f6b-9a2b3c4d5e6f'; +const String _slug = 'grandmas-lasagna'; + +/// Representative `GET /api/recipes/{slug}` detail payload. +Map _detailRecipe() => { + 'id': _recipeId, + 'slug': _slug, + 'name': "Grandma's Lasagna", + 'description': 'Slow-simmered Sunday comfort.', + 'prepTime': 'PT30M', + 'cookTime': 'PT1H30M', + 'recipeYield': '6 servings', + 'recipeIngredient': [ + { + 'quantity': 2, + 'unit': 'cup', + 'food': 'ricotta', + 'note': 'whole milk', + }, + { + 'quantity': null, + 'unit': null, + 'food': 'baking powder', + 'note': null, + }, + '1 pinch salt', + ], + 'recipeInstructions': [ + { + 'id': 'step-1', + 'name': 'Prep', + 'text': 'Boil the noodles until al dente.', + 'components': [], + }, + { + 'id': 'step-2', + 'name': 'Assembly', + 'text': '', + 'components': [], + }, + 'Bake uncovered for 45 minutes.', + ], + 'nutrition': { + 'calories': '512 kcal', + 'proteinContent': '30.5 g', + 'carbohydrateContent': '48 g', + 'fatContent': '22 g', + 'fiberContent': '6 g', + }, + }; + +/// Listing summary shape of `GET /api/recipes?perPage=-1` (no detail fields). +Map _summaryRecipe() => { + 'id': _recipeId, + 'slug': _slug, + 'name': "Grandma's Lasagna", + }; + +void main() { + group('MealieApiClient.mapRecipe', () { + test('maps identity, media, times and yield onto the domain Recipe', + () { + final recipe = MealieApiClient.mapRecipe( + _detailRecipe(), + baseUrl: '$_baseUrl/', + ); + + expect(recipe, isNotNull); + // The Mealie UUID is reused as the local primary key so re-syncs + // upsert the same row. + expect(recipe!.id, _recipeId); + expect(recipe.title, "Grandma's Lasagna"); + expect(recipe.description, 'Slow-simmered Sunday comfort.'); + expect(recipe.sourceUrl, '$_baseUrl/recipe/$_slug'); + expect( + recipe.imageUrl, + '$_baseUrl/api/media/recipes/$_recipeId/images/min-original.webp', + ); + expect(recipe.prepTimeMinutes, 30); + expect(recipe.cookTimeMinutes, 90); + expect(recipe.servings, 6); + }); + + test('maps structured and legacy string ingredients', () { + final recipe = MealieApiClient.mapRecipe( + _detailRecipe(), + baseUrl: _baseUrl, + )!; + + expect(recipe.ingredients, hasLength(3)); + + // Structured map entry: quantity/unit/food/note fields are preserved + // exactly and the note rides after a comma in the raw text. + final ricotta = recipe.ingredients[0]; + expect(ricotta.rawText, '2 cup ricotta, whole milk'); + expect(ricotta.quantity, 2); + expect(ricotta.unit, 'cup'); + expect(ricotta.parsedItemName, 'ricotta, whole milk'); + + // Null quantity/unit stay null (never coerced to 0 / ''). + final powder = recipe.ingredients[1]; + expect(powder.rawText, 'baking powder'); + expect(powder.quantity, isNull); + expect(powder.unit, isNull); + expect(powder.parsedItemName, 'baking powder'); + + // Legacy plain-string entry falls through to the shared line parser. + final salt = recipe.ingredients[2]; + expect(salt.quantity, 1); + expect(salt.unit, 'pinch'); + expect(salt.parsedItemName, 'salt'); + }); + + test('ingredients survive the repository JSON serialization round-trip', + () { + final recipe = MealieApiClient.mapRecipe( + _detailRecipe(), + baseUrl: _baseUrl, + )!; + final serialized = + jsonEncode(recipe.ingredients.map((e) => e.toMap()).toList()); + final restored = (jsonDecode(serialized) as List) + .map( + (entry) => RecipeIngredient.fromMap( + Map.from(entry as Map), + ), + ) + .toList(); + + expect(restored, hasLength(recipe.ingredients.length)); + for (var i = 0; i < restored.length; i++) { + expect(restored[i].rawText, recipe.ingredients[i].rawText); + expect(restored[i].parsedItemName, recipe.ingredients[i].parsedItemName); + expect(restored[i].quantity, recipe.ingredients[i].quantity); + expect(restored[i].unit, recipe.ingredients[i].unit); + } + }); + + test('flattens instruction sections into ordered steps', () { + final recipe = MealieApiClient.mapRecipe( + _detailRecipe(), + baseUrl: _baseUrl, + )!; + + expect(recipe.instructions, [ + 'Boil the noodles until al dente.', + // A section without text falls back to its name. + 'Assembly', + 'Bake uncovered for 45 minutes.', + ]); + }); + + test('parses nutrition display strings into the MacroProfile', () { + final recipe = MealieApiClient.mapRecipe( + _detailRecipe(), + baseUrl: _baseUrl, + )!; + + final macros = recipe.macrosPerServing; + expect(macros.calories, 512); + expect(macros.proteinGrams, closeTo(30.5, 0.001)); + expect(macros.carbsGrams, closeTo(48, 0.001)); + expect(macros.fatGrams, closeTo(22, 0.001)); + expect(macros.isEmpty, isFalse); + }); + + test('generates a fallback id when Mealie omits it', () { + final payload = { + 'slug': 'no-id-lasagna', + 'name': 'No-ID Lasagna', + 'recipeYieldQuantity': 2, + }; + final recipe = MealieApiClient.mapRecipe(payload, baseUrl: _baseUrl)!; + + expect( + RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', + ).hasMatch(recipe.id), + isTrue, + reason: 'a version-4 UUID is generated locally', + ); + expect(recipe.sourceUrl, '$_baseUrl/recipe/no-id-lasagna'); + expect(recipe.servings, 2); + expect(recipe.ingredients, isEmpty); + expect(recipe.instructions, isEmpty); + expect(recipe.macrosPerServing.isEmpty, isTrue); + }); + + test('rejects payloads without a usable title', () { + expect( + MealieApiClient.mapRecipe( + {'slug': 'untitled'}, + baseUrl: _baseUrl, + ), + isNull, + ); + expect( + MealieApiClient.mapRecipe( + {'name': ' '}, + baseUrl: _baseUrl, + ), + isNull, + ); + }); + }); + + group('MealieApiClient.fetchAllRecipes', () { + test( + 'lists with perPage=-1 + Bearer auth, enriches by slug, and builds ' + 'the §4.9.2 media/source URLs', () async { + final requests = []; + final client = MockClient((request) async { + requests.add(request); + if (request.url.path == '/api/recipes' && + request.url.queryParameters['perPage'] == '-1') { + return http.Response(jsonEncode([_summaryRecipe()]), 200); + } + if (request.url.path == '/api/recipes/$_slug') { + return http.Response(jsonEncode(_detailRecipe()), 200); + } + return http.Response('not found', 404); + }); + + final recipes = await MealieApiClient(client: client) + .fetchAllRecipes('$_baseUrl/', 'SECRET_TOKEN'); + + expect(requests, hasLength(2)); + expect( + requests.first.url.toString(), + '$_baseUrl/api/recipes?perPage=-1', + ); + expect(requests.first.headers['Authorization'], 'Bearer SECRET_TOKEN'); + expect(requests.last.url.path, '/api/recipes/$_slug'); + + expect(recipes, hasLength(1)); + final recipe = recipes.single; + expect(recipe.id, _recipeId); + expect(recipe.sourceUrl, '$_baseUrl/recipe/$_slug'); + expect( + recipe.imageUrl, + '$_baseUrl/api/media/recipes/$_recipeId/images/min-original.webp', + ); + // Detail fields override the bare summary. + expect(recipe.prepTimeMinutes, 30); + expect(recipe.ingredients, hasLength(3)); + expect(recipe.instructions, hasLength(3)); + expect(recipe.macrosPerServing.calories, 512); + }); + + test('degrades to the summary when the detail fetch fails', () async { + final client = MockClient((request) async { + if (request.url.queryParameters.containsKey('perPage')) { + return http.Response(jsonEncode([_summaryRecipe()]), 200); + } + return http.Response('boom', 500); + }); + + final recipes = await MealieApiClient(client: client) + .fetchAllRecipes(_baseUrl, 'token'); + + expect(recipes, hasLength(1)); + final recipe = recipes.single; + expect(recipe.title, "Grandma's Lasagna"); + expect(recipe.ingredients, isEmpty); + expect(recipe.macrosPerServing.isEmpty, isTrue); + }); + + test('throws MealieApiException when the listing is rejected', () async { + final client = + MockClient((request) async => http.Response('denied', 401)); + + await expectLater( + MealieApiClient(client: client).fetchAllRecipes(_baseUrl, 'bad'), + throwsA(isA()), + ); + }); + }); + + group('MealieApiClient.testConnection', () { + test('returns true when /api/app/about answers 200', () async { + late Uri probed; + final client = MockClient((request) async { + probed = request.url; + return http.Response('{"version": "v1.0.0"}', 200); + }); + + final ok = await MealieApiClient(client: client) + .testConnection(_baseUrl, 'TOKEN'); + + expect(ok, isTrue); + expect(probed.toString(), '$_baseUrl/api/app/about'); + }); + + test('falls back to the recipes probe when /about is unavailable', + () async { + final paths = []; + final client = MockClient((request) async { + paths.add(request.url.toString()); + if (request.url.path == '/api/app/about') { + return http.Response('forbidden', 403); + } + return http.Response('[]', 200); + }); + + final ok = await MealieApiClient(client: client) + .testConnection('$_baseUrl/', 'TOKEN'); + + expect(ok, isTrue); + expect(paths, contains('$_baseUrl/api/app/about')); + expect(paths, contains('$_baseUrl/api/recipes?perPage=1')); + }); + + test('returns false when every probe fails or the server is unreachable', + () async { + final rejecting = MealieApiClient( + client: MockClient((request) async => http.Response('nope', 401)), + ); + expect(await rejecting.testConnection(_baseUrl, 'TOKEN'), isFalse); + + final unreachable = MealieApiClient( + client: MockClient((request) async => throw Exception('offline')), + ); + expect(await unreachable.testConnection(_baseUrl, 'TOKEN'), isFalse); + + // Empty inputs never hit the network. + expect(await rejecting.testConnection('', 'TOKEN'), isFalse); + expect(await rejecting.testConnection(_baseUrl, ' '), isFalse); + }); + }); + + group('MealieApiClient.isMealieSourceUrl', () { + test('matches the configured instance and tolerates scheme drift', () { + expect( + MealieApiClient.isMealieSourceUrl( + '$_baseUrl/recipe/$_slug', + _baseUrl, + ), + isTrue, + ); + expect( + MealieApiClient.isMealieSourceUrl( + '$_baseUrl/recipe/$_slug', + '$_baseUrl/', + ), + isTrue, + ); + // Same host, different scheme — previously synced rows stay badged. + expect( + MealieApiClient.isMealieSourceUrl( + '$_baseUrl/recipe/$_slug', + 'http://mealie.kellfamilyhome.com', + ), + isTrue, + ); + expect( + MealieApiClient.isMealieSourceUrl( + 'https://example.com/recipe/best-lasagna', + _baseUrl, + ), + isFalse, + ); + expect( + MealieApiClient.isMealieSourceUrl('$_baseUrl/g/home/recipe/x', _baseUrl), + isFalse, + ); + expect(MealieApiClient.isMealieSourceUrl('anything', ''), isFalse); + }); + }); +}