diff --git a/lib/data/nostr/models/nostr_relay.dart b/lib/data/nostr/models/nostr_relay.dart index 9a23c89..65f7048 100644 --- a/lib/data/nostr/models/nostr_relay.dart +++ b/lib/data/nostr/models/nostr_relay.dart @@ -6,10 +6,14 @@ class NostrRelay { /// Whether the relay is currently connected. bool isConnected; + /// Whether the relay is enabled (should be used). + bool isEnabled; + /// Creates a [NostrRelay] instance. NostrRelay({ required this.url, this.isConnected = false, + this.isEnabled = true, }); /// Creates a [NostrRelay] from a URL string. @@ -19,7 +23,7 @@ class NostrRelay { @override String toString() { - return 'NostrRelay(url: $url, connected: $isConnected)'; + return 'NostrRelay(url: $url, connected: $isConnected, enabled: $isEnabled)'; } @override diff --git a/lib/data/nostr/nostr_service.dart b/lib/data/nostr/nostr_service.dart index a3b8df4..6617795 100644 --- a/lib/data/nostr/nostr_service.dart +++ b/lib/data/nostr/nostr_service.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:nostr_tools/nostr_tools.dart'; +import 'package:http/http.dart' as http; import 'models/nostr_keypair.dart'; import 'models/nostr_event.dart'; import 'models/nostr_relay.dart'; @@ -66,6 +67,35 @@ class NostrService { disconnectRelay(relayUrl); } + /// Enables or disables a relay. + /// + /// [relayUrl] - The URL of the relay to enable/disable. + /// [enabled] - Whether the relay should be enabled. + void setRelayEnabled(String relayUrl, bool enabled) { + final relay = _relays.firstWhere( + (r) => r.url == relayUrl, + orElse: () => throw NostrException('Relay not found: $relayUrl'), + ); + relay.isEnabled = enabled; + + // If disabling, also disconnect + if (!enabled && relay.isConnected) { + disconnectRelay(relayUrl); + } + } + + /// Toggles all relays enabled/disabled. + /// + /// [enabled] - Whether all relays should be enabled. + void setAllRelaysEnabled(bool enabled) { + for (final relay in _relays) { + relay.isEnabled = enabled; + if (!enabled && relay.isConnected) { + disconnectRelay(relay.url); + } + } + } + /// Gets the list of configured relays. List getRelays() { return List.unmodifiable(_relays); @@ -80,6 +110,15 @@ class NostrService { /// Throws [NostrException] if connection fails. Future>> connectRelay(String relayUrl) async { try { + // Check if relay is enabled + final relay = _relays.firstWhere( + (r) => r.url == relayUrl, + orElse: () => NostrRelay.fromUrl(relayUrl), + ); + if (!relay.isEnabled) { + throw NostrException('Relay is disabled: $relayUrl'); + } + if (_connections.containsKey(relayUrl) && _connections[relayUrl] != null) { // Already connected return _messageControllers[relayUrl]!.stream; @@ -95,11 +134,10 @@ class NostrService { } _connections[relayUrl] = channel; - final controller = StreamController>(); + final controller = StreamController>.broadcast(); _messageControllers[relayUrl] = controller; - // Update relay status - final relay = _relays.firstWhere((r) => r.url == relayUrl, orElse: () => NostrRelay.fromUrl(relayUrl)); + // Update relay status (relay already found above) relay.isConnected = true; // Listen for messages @@ -303,7 +341,8 @@ class NostrService { /// Fetches profile from a specific relay. Future _fetchProfileFromRelay(String publicKey, String relayUrl, Duration timeout) async { final channel = _connections[relayUrl]; - if (channel == null) { + final messageController = _messageControllers[relayUrl]; + if (channel == null || messageController == null) { return null; } @@ -312,7 +351,7 @@ class NostrService { final reqId = 'profile_${DateTime.now().millisecondsSinceEpoch}'; final completer = Completer(); - final subscription = _messageControllers[relayUrl]?.stream.listen( + final subscription = messageController.stream.listen( (message) { // Message format from connectRelay: // {'type': 'EVENT', 'subscription_id': , 'data': } @@ -413,6 +452,118 @@ class NostrService { } } + /// Fetches preferred relays from a NIP-05 identifier. + /// + /// NIP-05 verification endpoint format: https:///.well-known/nostr.json?name= + /// The response can include relay hints in the format: + /// { + /// "names": { "": "" }, + /// "relays": { "": ["wss://relay1.com", "wss://relay2.com"] } + /// } + /// + /// [nip05] - The NIP-05 identifier (e.g., 'user@domain.com'). + /// [publicKey] - The public key (hex format) to match against relay hints. + /// + /// Returns a list of preferred relay URLs, or empty list if none found. + /// + /// Throws [NostrException] if fetch fails. + Future> fetchPreferredRelaysFromNip05( + String nip05, + String publicKey, + ) async { + try { + // Parse NIP-05 identifier (format: local-part@domain) + final parts = nip05.split('@'); + if (parts.length != 2) { + throw NostrException('Invalid NIP-05 format: $nip05'); + } + + final localPart = parts[0]; + final domain = parts[1]; + + // Construct the verification URL + final url = Uri.https(domain, '/.well-known/nostr.json', {'name': localPart}); + + // Fetch the NIP-05 verification data + final response = await http.get(url).timeout( + const Duration(seconds: 10), + onTimeout: () { + throw NostrException('Timeout fetching NIP-05 data'); + }, + ); + + if (response.statusCode != 200) { + throw NostrException('Failed to fetch NIP-05 data: ${response.statusCode}'); + } + + // Parse the JSON response + final data = jsonDecode(response.body) as Map; + + // Extract relay hints for the public key + final relays = data['relays'] as Map?; + if (relays == null) { + return []; + } + + // Find relays for the matching public key (case-insensitive) + final publicKeyLower = publicKey.toLowerCase(); + for (final entry in relays.entries) { + final keyLower = entry.key.toLowerCase(); + if (keyLower == publicKeyLower) { + final relayList = entry.value; + if (relayList is List) { + return relayList + .map((r) => r.toString()) + .where((r) => r.isNotEmpty) + .toList(); + } + } + } + + return []; + } catch (e) { + if (e is NostrException) { + rethrow; + } + throw NostrException('Failed to fetch preferred relays from NIP-05: $e'); + } + } + + /// Loads preferred relays from NIP-05 if available and adds them to the relay list. + /// + /// [nip05] - The NIP-05 identifier (e.g., 'user@domain.com'). + /// [publicKey] - The public key (hex format) to match against relay hints. + /// + /// Returns the number of relays added. + /// + /// Throws [NostrException] if fetch fails. + Future loadPreferredRelaysFromNip05( + String nip05, + String publicKey, + ) async { + try { + final preferredRelays = await fetchPreferredRelaysFromNip05(nip05, publicKey); + + int addedCount = 0; + for (final relayUrl in preferredRelays) { + try { + addRelay(relayUrl); + addedCount++; + } catch (e) { + // Skip invalid relay URLs + debugPrint('Warning: Invalid relay URL from NIP-05: $relayUrl'); + } + } + + return addedCount; + } catch (e) { + if (e is NostrException) { + rethrow; + } + throw NostrException('Failed to load preferred relays from NIP-05: $e'); + } + } + /// Closes all connections and cleans up resources. void dispose() { for (final relayUrl in _connections.keys.toList()) { diff --git a/lib/data/session/session_service.dart b/lib/data/session/session_service.dart index 5f64c14..994d09d 100644 --- a/lib/data/session/session_service.dart +++ b/lib/data/session/session_service.dart @@ -196,6 +196,9 @@ class SessionService { // Set as current user _currentUser = user; + // Load preferred relays from NIP-05 if available + await _loadPreferredRelaysIfAvailable(); + return user; } catch (e) { throw SessionException('Failed to login with Nostr: $e'); @@ -360,5 +363,46 @@ class SessionService { if (_currentUser == null) return null; return _userCacheDirs[_currentUser!.id]; } + + /// Loads preferred relays from NIP-05 if the current user has an active nip05. + /// + /// This method checks if the logged-in user has a nip05 identifier and, + /// if so, fetches preferred relays from the NIP-05 verification endpoint + /// and adds them to the Nostr service relay list. + /// + /// This is called automatically after Nostr login, but can also be called + /// manually to refresh preferred relays. + Future loadPreferredRelaysIfAvailable() async { + await _loadPreferredRelaysIfAvailable(); + } + + /// Internal method to load preferred relays from NIP-05 if available. + Future _loadPreferredRelaysIfAvailable() async { + if (_currentUser == null || _nostrService == null) { + return; + } + + final profile = _currentUser!.nostrProfile; + if (profile == null || profile.nip05 == null || profile.nip05!.isEmpty) { + return; + } + + try { + final nip05 = profile.nip05!; + final publicKey = _currentUser!.id; // User ID is the public key for Nostr users + + final addedCount = await _nostrService!.loadPreferredRelaysFromNip05( + nip05, + publicKey, + ); + + if (addedCount > 0) { + debugPrint('Loaded $addedCount preferred relay(s) from NIP-05: $nip05'); + } + } catch (e) { + // Log error but don't fail - offline-first behavior + debugPrint('Warning: Failed to load preferred relays from NIP-05: $e'); + } + } } diff --git a/lib/ui/navigation/app_router.dart b/lib/ui/navigation/app_router.dart index 17c8eb8..91a6dc7 100644 --- a/lib/ui/navigation/app_router.dart +++ b/lib/ui/navigation/app_router.dart @@ -131,6 +131,7 @@ class AppRouter { builder: (_) => SessionScreen( sessionService: sessionService, firebaseService: firebaseService, + nostrService: nostrService, ), settings: settings, ); diff --git a/lib/ui/navigation/main_navigation_scaffold.dart b/lib/ui/navigation/main_navigation_scaffold.dart index ef8d4d8..f17bf30 100644 --- a/lib/ui/navigation/main_navigation_scaffold.dart +++ b/lib/ui/navigation/main_navigation_scaffold.dart @@ -92,6 +92,7 @@ class _MainNavigationScaffoldState extends State { return SessionScreen( sessionService: widget.sessionService, firebaseService: widget.firebaseService, + nostrService: widget.nostrService, onSessionChanged: _onSessionStateChanged, ); case 4: diff --git a/lib/ui/relay_management/relay_management_controller.dart b/lib/ui/relay_management/relay_management_controller.dart index f33b71a..cca1672 100644 --- a/lib/ui/relay_management/relay_management_controller.dart +++ b/lib/ui/relay_management/relay_management_controller.dart @@ -118,6 +118,68 @@ class RelayManagementController extends ChangeNotifier { } } + /// Tests connectivity to a single relay. + /// + /// [relayUrl] - The URL of the relay to test. + /// + /// Returns true if connection successful, false otherwise. + Future testRelay(String relayUrl) async { + _error = null; + notifyListeners(); + + try { + final stream = await nostrService + .connectRelay(relayUrl) + .timeout( + const Duration(seconds: 3), + onTimeout: () { + throw Exception('Connection timeout'); + }, + ); + _loadRelays(); + // Cancel the stream subscription to clean up + stream.listen(null).cancel(); + return true; + } catch (e) { + // Connection failed - disconnect to mark as unhealthy + try { + nostrService.disconnectRelay(relayUrl); + _loadRelays(); + } catch (_) { + // Ignore disconnect errors + } + return false; + } + } + + /// Toggles a relay on/off (enables/disables it). + /// + /// [relayUrl] - The URL of the relay to toggle. + void toggleRelay(String relayUrl) { + try { + _error = null; + final relay = _relays.firstWhere((r) => r.url == relayUrl); + nostrService.setRelayEnabled(relayUrl, !relay.isEnabled); + _loadRelays(); + } catch (e) { + _error = 'Failed to toggle relay: $e'; + notifyListeners(); + } + } + + /// Toggles all relays on/off. + void toggleAllRelays() { + try { + _error = null; + final allEnabled = _relays.every((r) => r.isEnabled); + nostrService.setAllRelaysEnabled(!allEnabled); + _loadRelays(); + } catch (e) { + _error = 'Failed to toggle all relays: $e'; + notifyListeners(); + } + } + /// Checks health of all relays by attempting to connect. /// /// Updates relay connection status. diff --git a/lib/ui/relay_management/relay_management_screen.dart b/lib/ui/relay_management/relay_management_screen.dart index fb0bb3f..73c8fa1 100644 --- a/lib/ui/relay_management/relay_management_screen.dart +++ b/lib/ui/relay_management/relay_management_screen.dart @@ -4,8 +4,7 @@ import '../../data/nostr/models/nostr_relay.dart'; /// Screen for managing Nostr relays. /// -/// Allows users to view, add, remove, and monitor relay health, -/// and trigger manual syncs. +/// Allows users to view, add, remove, test, and toggle relays. class RelayManagementScreen extends StatefulWidget { /// Controller for managing relay state. final RelayManagementController controller; @@ -22,6 +21,7 @@ class RelayManagementScreen extends StatefulWidget { class _RelayManagementScreenState extends State { final TextEditingController _urlController = TextEditingController(); + final Map _testingRelays = {}; @override void dispose() { @@ -29,6 +29,31 @@ class _RelayManagementScreenState extends State { super.dispose(); } + Future _handleTestRelay(String relayUrl) async { + setState(() { + _testingRelays[relayUrl] = true; + }); + + final success = await widget.controller.testRelay(relayUrl); + + setState(() { + _testingRelays[relayUrl] = false; + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + success + ? 'Relay test successful' + : 'Relay test failed', + ), + duration: const Duration(seconds: 2), + ), + ); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -65,12 +90,48 @@ class _RelayManagementScreenState extends State { ), ), - // Actions section + // Top action buttons Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // Test All and Toggle All buttons + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: widget.controller.isCheckingHealth + ? null + : widget.controller.checkRelayHealth, + icon: widget.controller.isCheckingHealth + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.network_check), + label: const Text('Test All'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton.icon( + onPressed: widget.controller.relays.isEmpty + ? null + : widget.controller.toggleAllRelays, + icon: const Icon(Icons.power_settings_new), + label: Text( + widget.controller.relays.isNotEmpty && + widget.controller.relays.every((r) => r.isEnabled) + ? 'Turn All Off' + : 'Turn All On', + ), + ), + ), + ], + ), + const SizedBox(height: 16), // Add relay input Row( children: [ @@ -79,9 +140,7 @@ class _RelayManagementScreenState extends State { controller: _urlController, decoration: InputDecoration( labelText: 'Relay URL', - hintText: widget.controller.relays.isNotEmpty - ? widget.controller.relays.first.url - : 'wss://nostrum.satoshinakamoto.win', + hintText: 'wss://relay.example.com', border: const OutlineInputBorder(), ), keyboardType: TextInputType.url, @@ -108,56 +167,6 @@ class _RelayManagementScreenState extends State { ), ], ), - const SizedBox(height: 16), - - // Action buttons - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - ElevatedButton.icon( - onPressed: widget.controller.isCheckingHealth - ? null - : widget.controller.checkRelayHealth, - icon: widget.controller.isCheckingHealth - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.health_and_safety), - label: const Text('Check Health'), - ), - if (widget.controller.syncEngine != null) - ElevatedButton.icon( - onPressed: widget.controller.isSyncing - ? null - : () async { - final success = await widget.controller.triggerManualSync(); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - success - ? 'Sync triggered successfully' - : 'Sync failed: ${widget.controller.error ?? "Unknown error"}', - ), - duration: const Duration(seconds: 2), - ), - ); - } - }, - icon: widget.controller.isSyncing - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.sync), - label: const Text('Manual Sync'), - ), - ], - ), ], ), ), @@ -199,8 +208,9 @@ class _RelayManagementScreenState extends State { final relay = widget.controller.relays[index]; return _RelayListItem( relay: relay, - onConnect: () => widget.controller.connectRelay(relay.url), - onDisconnect: () => widget.controller.disconnectRelay(relay.url), + isTesting: _testingRelays[relay.url] ?? false, + onTest: () => _handleTestRelay(relay.url), + onToggle: () => widget.controller.toggleRelay(relay.url), onRemove: () { widget.controller.removeRelay(relay.url); ScaffoldMessenger.of(context).showSnackBar( @@ -227,19 +237,23 @@ class _RelayListItem extends StatelessWidget { /// The relay to display. final NostrRelay relay; - /// Callback when connect is pressed. - final VoidCallback onConnect; + /// Whether the relay is currently being tested. + final bool isTesting; + + /// Callback when test is pressed. + final VoidCallback onTest; - /// Callback when disconnect is pressed. - final VoidCallback onDisconnect; + /// Callback when toggle is pressed. + final VoidCallback onToggle; /// Callback when remove is pressed. final VoidCallback onRemove; const _RelayListItem({ required this.relay, - required this.onConnect, - required this.onDisconnect, + required this.isTesting, + required this.onTest, + required this.onToggle, required this.onRemove, }); @@ -247,40 +261,103 @@ class _RelayListItem extends StatelessWidget { Widget build(BuildContext context) { return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: ListTile( - leading: CircleAvatar( - backgroundColor: relay.isConnected ? Colors.green : Colors.grey, - child: Icon( - relay.isConnected ? Icons.check : Icons.close, - color: Colors.white, - size: 20, - ), - ), - title: Text( - relay.url, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - subtitle: Text( - relay.isConnected ? 'Connected' : 'Disconnected', - style: TextStyle( - color: relay.isConnected ? Colors.green : Colors.grey, - ), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - IconButton( - icon: Icon( - relay.isConnected ? Icons.link_off : Icons.link, - color: relay.isConnected ? Colors.orange : Colors.green, + // Relay URL and status + Row( + children: [ + // Status indicator + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: relay.isConnected + ? Colors.green + : relay.isEnabled + ? Colors.orange + : Colors.grey, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + relay.url, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + // Status text + Text( + relay.isConnected + ? 'Connected' + : relay.isEnabled + ? 'Enabled (not connected)' + : 'Disabled', + style: TextStyle( + fontSize: 12, + color: relay.isConnected + ? Colors.green + : relay.isEnabled + ? Colors.orange + : Colors.grey, ), - tooltip: relay.isConnected ? 'Disconnect' : 'Connect', - onPressed: relay.isConnected ? onDisconnect : onConnect, ), - IconButton( - icon: const Icon(Icons.delete, color: Colors.red), - tooltip: 'Remove', - onPressed: onRemove, + const SizedBox(height: 12), + // Action buttons + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // Test button + OutlinedButton.icon( + onPressed: isTesting ? null : onTest, + icon: isTesting + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.network_check, size: 16), + label: const Text('Test'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + ), + const SizedBox(width: 8), + // Toggle switch + Row( + children: [ + Text( + relay.isEnabled ? 'On' : 'Off', + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + const SizedBox(width: 4), + Switch( + value: relay.isEnabled, + onChanged: (_) => onToggle(), + ), + ], + ), + const SizedBox(width: 8), + // Remove button + IconButton( + icon: const Icon(Icons.delete, size: 20), + color: Colors.red, + tooltip: 'Remove', + onPressed: onRemove, + ), + ], ), ], ), @@ -288,4 +365,3 @@ class _RelayListItem extends StatelessWidget { ); } } - diff --git a/lib/ui/session/session_screen.dart b/lib/ui/session/session_screen.dart index 1eb9496..cd92657 100644 --- a/lib/ui/session/session_screen.dart +++ b/lib/ui/session/session_screen.dart @@ -1,17 +1,20 @@ import 'package:flutter/material.dart'; import '../../data/session/session_service.dart'; import '../../data/firebase/firebase_service.dart'; +import '../../data/nostr/nostr_service.dart'; /// Screen for user session management (login/logout). class SessionScreen extends StatefulWidget { final SessionService? sessionService; final FirebaseService? firebaseService; + final NostrService? nostrService; final VoidCallback? onSessionChanged; const SessionScreen({ super.key, this.sessionService, this.firebaseService, + this.nostrService, this.onSessionChanged, }); @@ -342,6 +345,17 @@ class _SessionScreenState extends State { const Divider(), const SizedBox(height: 12), ], + // NIP-05 section + if (currentUser.nostrProfile?.nip05 != null && + currentUser.nostrProfile!.nip05!.isNotEmpty) + _Nip05Section( + nip05: currentUser.nostrProfile!.nip05!, + publicKey: currentUser.id, + nostrService: widget.nostrService, + ), + if (currentUser.nostrProfile?.nip05 != null && + currentUser.nostrProfile!.nip05!.isNotEmpty) + const SizedBox(height: 12), Text('User ID: ${currentUser.id.substring(0, currentUser.id.length > 32 ? 32 : currentUser.id.length)}${currentUser.id.length > 32 ? '...' : ''}'), Text('Username: ${currentUser.username}'), Text( @@ -494,3 +508,183 @@ class _SessionScreenState extends State { } } +/// Widget for displaying NIP-05 information including domain and preferred relays. +class _Nip05Section extends StatefulWidget { + final String nip05; + final String publicKey; + final NostrService? nostrService; + + const _Nip05Section({ + required this.nip05, + required this.publicKey, + this.nostrService, + }); + + @override + State<_Nip05Section> createState() => _Nip05SectionState(); +} + +class _Nip05SectionState extends State<_Nip05Section> { + List _preferredRelays = []; + bool _isLoading = false; + String? _error; + + @override + void initState() { + super.initState(); + _loadPreferredRelays(); + } + + Future _loadPreferredRelays() async { + if (widget.nostrService == null) { + setState(() { + _error = 'Nostr service not available'; + }); + return; + } + + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final relays = await widget.nostrService!.fetchPreferredRelaysFromNip05( + widget.nip05, + widget.publicKey, + ); + setState(() { + _preferredRelays = relays; + _isLoading = false; + }); + } catch (e) { + setState(() { + _error = e.toString().replaceAll('NostrException: ', ''); + _isLoading = false; + }); + } + } + + String _getDomain() { + final parts = widget.nip05.split('@'); + if (parts.length == 2) { + return parts[1]; + } + return widget.nip05; + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'NIP-05', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + const Icon(Icons.verified, size: 16, color: Colors.blue), + const SizedBox(width: 8), + Expanded( + child: Text( + widget.nip05, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Domain: ${_getDomain()}', + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 12), + const Text( + 'Preferred Relays', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + if (_isLoading) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else if (_error != null) + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.red.shade200), + ), + child: Row( + children: [ + Icon(Icons.error_outline, size: 16, color: Colors.red.shade700), + const SizedBox(width: 8), + Expanded( + child: Text( + _error!, + style: TextStyle( + fontSize: 12, + color: Colors.red.shade700, + ), + ), + ), + ], + ), + ) + else if (_preferredRelays.isEmpty) + Text( + 'No preferred relays found', + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + fontStyle: FontStyle.italic, + ), + ) + else + ..._preferredRelays.map((relay) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + Icon( + Icons.link, + size: 14, + color: Colors.grey[600], + ), + const SizedBox(width: 8), + Expanded( + child: Text( + relay, + style: TextStyle( + fontSize: 12, + color: Colors.grey[700], + ), + ), + ), + ], + ), + )), + ], + ); + } +} + diff --git a/test/data/firebase/firebase_service_test.dart b/test/data/firebase/firebase_service_test.dart index b279ad5..b73dff0 100644 --- a/test/data/firebase/firebase_service_test.dart +++ b/test/data/firebase/firebase_service_test.dart @@ -17,7 +17,6 @@ void main() { databaseFactory = databaseFactoryFfi; late MockLocalStorageService mockLocalStorage; - late FirebaseService firebaseService; late Directory tempDir; setUp(() async { diff --git a/test/ui/relay_management/relay_management_screen_test.dart b/test/ui/relay_management/relay_management_screen_test.dart index a0b3f69..2db76f8 100644 --- a/test/ui/relay_management/relay_management_screen_test.dart +++ b/test/ui/relay_management/relay_management_screen_test.dart @@ -92,7 +92,8 @@ void main() { expect(find.textContaining('wss://relay2.example.com'), findsWidgets); // Verify we have relay list items (Cards) expect(find.byType(Card), findsNWidgets(2)); - expect(find.text('Disconnected'), findsNWidgets(2)); + // New UI shows "Enabled (not connected)" or "Disabled" instead of "Disconnected" + expect(find.textContaining('Enabled'), findsWidgets); }); testWidgets('adds relay when Add button is pressed', @@ -157,16 +158,15 @@ void main() { testWidgets('displays check health button', (WidgetTester tester) async { await tester.pumpWidget(createTestWidget()); - expect(find.text('Check Health'), findsOneWidget); - expect(find.byIcon(Icons.health_and_safety), findsOneWidget); + expect(find.text('Test All'), findsOneWidget); + expect(find.byIcon(Icons.network_check), findsOneWidget); }); - testWidgets('displays manual sync button when sync engine is configured', - (WidgetTester tester) async { + testWidgets('displays toggle all button', (WidgetTester tester) async { await tester.pumpWidget(createTestWidget()); - expect(find.text('Manual Sync'), findsOneWidget); - expect(find.byIcon(Icons.sync), findsOneWidget); + expect(find.text('Turn All On'), findsOneWidget); + expect(find.byIcon(Icons.power_settings_new), findsOneWidget); }); testWidgets('shows loading state during health check', @@ -175,9 +175,9 @@ void main() { await tester.pumpWidget(createTestWidget()); await tester.pump(); - // Tap check health button - final healthButton = find.text('Check Health'); - await tester.tap(healthButton); + // Tap test all button + final testAllButton = find.text('Test All'); + await tester.tap(testAllButton); await tester.pump(); // Check for loading indicator (may be brief) @@ -231,12 +231,14 @@ void main() { await tester.pumpWidget(createTestWidget()); await tester.pump(); - // Verify relay URL is displayed (may appear in ListTile title) + // Verify relay URL is displayed expect(find.textContaining('wss://relay.example.com'), findsWidgets); - // Verify status indicator is present - expect(find.byType(CircleAvatar), findsWidgets); - // Verify we have a relay card + // Verify status indicator is present (now a Container with decoration, not CircleAvatar) + // The status indicator is a Container with BoxDecoration, so we check for the Card instead expect(find.byType(Card), findsWidgets); + // Verify we have test button and toggle switch + expect(find.text('Test'), findsWidgets); + expect(find.byType(Switch), findsWidgets); }); }); }