222 lines
8.0 KiB
Dart
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,
|
|
);
|