glp-companion/lib/features/recipes/presentation/controllers/mealie_integration_controll...

222 lines
8.0 KiB
Dart

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<MealieApiClient>(
(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<DriftRecipeRepository?>((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<MealieIntegrationState> {
/// 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<void>.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<void> _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<String?> 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<void> 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<void> 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, MealieIntegrationState>(
MealieIntegrationController.new,
);