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)'; }