import 'dart:async'; import 'package:flutter/foundation.dart'; import '../../data/nostr/nostr_service.dart'; import '../../data/nostr/models/nostr_relay.dart'; import '../../data/sync/sync_engine.dart'; /// Controller for managing Nostr relay UI state and operations. /// /// This controller separates business logic from UI presentation, /// making the UI module testable and modular. class RelayManagementController extends ChangeNotifier { /// Nostr service for relay operations. final NostrService nostrService; /// Sync engine for triggering manual syncs. final SyncEngine? syncEngine; /// List of relays being managed. List _relays = []; /// Whether a sync operation is in progress. bool _isSyncing = false; /// Error message if any operation fails. String? _error; /// Whether health check is in progress. bool _isCheckingHealth = false; /// Creates a [RelayManagementController] instance. RelayManagementController({ required this.nostrService, this.syncEngine, }) { _loadRelays(); } /// List of relays. List get relays => List.unmodifiable(_relays); /// Whether sync is in progress. bool get isSyncing => _isSyncing; /// Current error message, if any. String? get error => _error; /// Whether health check is in progress. bool get isCheckingHealth => _isCheckingHealth; /// Loads relays from the Nostr service. void _loadRelays() { _relays = nostrService.getRelays(); notifyListeners(); } /// Adds a relay to the service. /// /// [relayUrl] - The WebSocket URL of the relay. /// /// Returns true if the relay was added successfully, false if it already exists. bool addRelay(String relayUrl) { try { _error = null; // Validate URL format if (!relayUrl.startsWith('wss://') && !relayUrl.startsWith('ws://')) { _error = 'Invalid relay URL. Must start with wss:// or ws://'; notifyListeners(); return false; } nostrService.addRelay(relayUrl); _loadRelays(); return true; } catch (e) { _error = 'Failed to add relay: $e'; notifyListeners(); return false; } } /// Removes a relay from the service. /// /// [relayUrl] - The URL of the relay to remove. void removeRelay(String relayUrl) { try { _error = null; nostrService.removeRelay(relayUrl); _loadRelays(); } catch (e) { _error = 'Failed to remove relay: $e'; notifyListeners(); } } /// Connects to a relay and updates status. /// /// [relayUrl] - The URL of the relay to connect to. /// /// Throws if connection fails (for use in health checks where we want to catch individual failures). Future connectRelay(String relayUrl) async { _error = null; await nostrService.connectRelay(relayUrl); _loadRelays(); } /// Disconnects from a relay. /// /// [relayUrl] - The URL of the relay to disconnect from. void disconnectRelay(String relayUrl) { try { _error = null; nostrService.disconnectRelay(relayUrl); _loadRelays(); } catch (e) { _error = 'Failed to disconnect from relay: $e'; notifyListeners(); } } /// Checks health of all relays by attempting to connect. /// /// Updates relay connection status. /// /// Never throws - all connection failures are handled gracefully. Future checkRelayHealth() async { _isCheckingHealth = true; _error = null; notifyListeners(); // Process each relay health check, catching all errors final futures = >[]; for (final relay in _relays) { // Wrap each connection attempt to ensure all exceptions are caught futures.add( Future(() async { try { // Attempt to connect - this may throw synchronously or asynchronously // Wrap in another try-catch to catch synchronous exceptions from WebSocket.connect try { final stream = await nostrService .connectRelay(relay.url) .timeout( const Duration(seconds: 2), onTimeout: () { throw Exception('Connection timeout'); }, ); // If we get here, connection succeeded _loadRelays(); // Cancel the stream subscription to clean up stream.listen(null).cancel(); } catch (e) { // Connection failed - disconnect to mark as unhealthy try { nostrService.disconnectRelay(relay.url); _loadRelays(); } catch (_) { // Ignore disconnect errors } // Re-throw to be caught by outer catch throw e; } } catch (e) { // Catch all exceptions - connection failures are expected in tests // Disconnect relay to mark as unhealthy try { nostrService.disconnectRelay(relay.url); _loadRelays(); } catch (_) { // Ignore disconnect errors } // Don't re-throw - this method should never throw } }).catchError((_) { // Final safety net - ensure no exceptions escape try { nostrService.disconnectRelay(relay.url); _loadRelays(); } catch (_) { // Ignore all errors } return Future.value(); }), ); } // Wait for all health checks to complete (or fail gracefully) // Use eagerError: false so one failure doesn't stop others try { await Future.wait(futures, eagerError: false); } catch (_) { // Swallow any errors - all individual failures already handled above } _isCheckingHealth = false; notifyListeners(); } /// Triggers a manual sync using the sync engine. /// /// Returns true if sync was triggered successfully. Future triggerManualSync() async { if (syncEngine == null) { _error = 'Sync engine not configured'; notifyListeners(); return false; } _isSyncing = true; _error = null; notifyListeners(); try { // Trigger sync to all configured relays via Nostr // This is a simplified sync - in a real app, you'd sync specific items await syncEngine!.syncAll(); return true; } catch (e) { _error = 'Sync failed: $e'; return false; } finally { _isSyncing = false; notifyListeners(); } } /// Clears the current error message. void clearError() { _error = null; notifyListeners(); } @override void dispose() { // Don't dispose nostrService or syncEngine - they're managed elsewhere super.dispose(); } }