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, );