nostr login fetch details and nip05
This commit is contained in:
@@ -6,10 +6,14 @@ class NostrRelay {
|
|||||||
/// Whether the relay is currently connected.
|
/// Whether the relay is currently connected.
|
||||||
bool isConnected;
|
bool isConnected;
|
||||||
|
|
||||||
|
/// Whether the relay is enabled (should be used).
|
||||||
|
bool isEnabled;
|
||||||
|
|
||||||
/// Creates a [NostrRelay] instance.
|
/// Creates a [NostrRelay] instance.
|
||||||
NostrRelay({
|
NostrRelay({
|
||||||
required this.url,
|
required this.url,
|
||||||
this.isConnected = false,
|
this.isConnected = false,
|
||||||
|
this.isEnabled = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Creates a [NostrRelay] from a URL string.
|
/// Creates a [NostrRelay] from a URL string.
|
||||||
@@ -19,7 +23,7 @@ class NostrRelay {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'NostrRelay(url: $url, connected: $isConnected)';
|
return 'NostrRelay(url: $url, connected: $isConnected, enabled: $isEnabled)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
import 'package:nostr_tools/nostr_tools.dart';
|
import 'package:nostr_tools/nostr_tools.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
import 'models/nostr_keypair.dart';
|
import 'models/nostr_keypair.dart';
|
||||||
import 'models/nostr_event.dart';
|
import 'models/nostr_event.dart';
|
||||||
import 'models/nostr_relay.dart';
|
import 'models/nostr_relay.dart';
|
||||||
@@ -66,6 +67,35 @@ class NostrService {
|
|||||||
disconnectRelay(relayUrl);
|
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.
|
/// Gets the list of configured relays.
|
||||||
List<NostrRelay> getRelays() {
|
List<NostrRelay> getRelays() {
|
||||||
return List.unmodifiable(_relays);
|
return List.unmodifiable(_relays);
|
||||||
@@ -80,6 +110,15 @@ class NostrService {
|
|||||||
/// Throws [NostrException] if connection fails.
|
/// Throws [NostrException] if connection fails.
|
||||||
Future<Stream<Map<String, dynamic>>> connectRelay(String relayUrl) async {
|
Future<Stream<Map<String, dynamic>>> connectRelay(String relayUrl) async {
|
||||||
try {
|
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) {
|
if (_connections.containsKey(relayUrl) && _connections[relayUrl] != null) {
|
||||||
// Already connected
|
// Already connected
|
||||||
return _messageControllers[relayUrl]!.stream;
|
return _messageControllers[relayUrl]!.stream;
|
||||||
@@ -95,11 +134,10 @@ class NostrService {
|
|||||||
}
|
}
|
||||||
_connections[relayUrl] = channel;
|
_connections[relayUrl] = channel;
|
||||||
|
|
||||||
final controller = StreamController<Map<String, dynamic>>();
|
final controller = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
_messageControllers[relayUrl] = controller;
|
_messageControllers[relayUrl] = controller;
|
||||||
|
|
||||||
// Update relay status
|
// Update relay status (relay already found above)
|
||||||
final relay = _relays.firstWhere((r) => r.url == relayUrl, orElse: () => NostrRelay.fromUrl(relayUrl));
|
|
||||||
relay.isConnected = true;
|
relay.isConnected = true;
|
||||||
|
|
||||||
// Listen for messages
|
// Listen for messages
|
||||||
@@ -303,7 +341,8 @@ class NostrService {
|
|||||||
/// Fetches profile from a specific relay.
|
/// Fetches profile from a specific relay.
|
||||||
Future<NostrProfile?> _fetchProfileFromRelay(String publicKey, String relayUrl, Duration timeout) async {
|
Future<NostrProfile?> _fetchProfileFromRelay(String publicKey, String relayUrl, Duration timeout) async {
|
||||||
final channel = _connections[relayUrl];
|
final channel = _connections[relayUrl];
|
||||||
if (channel == null) {
|
final messageController = _messageControllers[relayUrl];
|
||||||
|
if (channel == null || messageController == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,7 +351,7 @@ class NostrService {
|
|||||||
final reqId = 'profile_${DateTime.now().millisecondsSinceEpoch}';
|
final reqId = 'profile_${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
|
||||||
final completer = Completer<NostrProfile?>();
|
final completer = Completer<NostrProfile?>();
|
||||||
final subscription = _messageControllers[relayUrl]?.stream.listen(
|
final subscription = messageController.stream.listen(
|
||||||
(message) {
|
(message) {
|
||||||
// Message format from connectRelay:
|
// Message format from connectRelay:
|
||||||
// {'type': 'EVENT', 'subscription_id': <id>, 'data': <event_json>}
|
// {'type': 'EVENT', 'subscription_id': <id>, 'data': <event_json>}
|
||||||
@@ -413,6 +452,118 @@ class NostrService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetches preferred relays from a NIP-05 identifier.
|
||||||
|
///
|
||||||
|
/// NIP-05 verification endpoint format: https://<domain>/.well-known/nostr.json?name=<local-part>
|
||||||
|
/// The response can include relay hints in the format:
|
||||||
|
/// {
|
||||||
|
/// "names": { "<local-part>": "<hex-pubkey>" },
|
||||||
|
/// "relays": { "<hex-pubkey>": ["wss://relay1.com", "wss://relay2.com"] }
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// [nip05] - The NIP-05 identifier (e.g., '[email protected]').
|
||||||
|
/// [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<List<String>> 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<String, dynamic>;
|
||||||
|
|
||||||
|
// Extract relay hints for the public key
|
||||||
|
final relays = data['relays'] as Map<String, dynamic>?;
|
||||||
|
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., '[email protected]').
|
||||||
|
/// [publicKey] - The public key (hex format) to match against relay hints.
|
||||||
|
///
|
||||||
|
/// Returns the number of relays added.
|
||||||
|
///
|
||||||
|
/// Throws [NostrException] if fetch fails.
|
||||||
|
Future<int> 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.
|
/// Closes all connections and cleans up resources.
|
||||||
void dispose() {
|
void dispose() {
|
||||||
for (final relayUrl in _connections.keys.toList()) {
|
for (final relayUrl in _connections.keys.toList()) {
|
||||||
|
|||||||
@@ -196,6 +196,9 @@ class SessionService {
|
|||||||
// Set as current user
|
// Set as current user
|
||||||
_currentUser = user;
|
_currentUser = user;
|
||||||
|
|
||||||
|
// Load preferred relays from NIP-05 if available
|
||||||
|
await _loadPreferredRelaysIfAvailable();
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw SessionException('Failed to login with Nostr: $e');
|
throw SessionException('Failed to login with Nostr: $e');
|
||||||
@@ -360,5 +363,46 @@ class SessionService {
|
|||||||
if (_currentUser == null) return null;
|
if (_currentUser == null) return null;
|
||||||
return _userCacheDirs[_currentUser!.id];
|
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<void> loadPreferredRelaysIfAvailable() async {
|
||||||
|
await _loadPreferredRelaysIfAvailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal method to load preferred relays from NIP-05 if available.
|
||||||
|
Future<void> _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');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ class AppRouter {
|
|||||||
builder: (_) => SessionScreen(
|
builder: (_) => SessionScreen(
|
||||||
sessionService: sessionService,
|
sessionService: sessionService,
|
||||||
firebaseService: firebaseService,
|
firebaseService: firebaseService,
|
||||||
|
nostrService: nostrService,
|
||||||
),
|
),
|
||||||
settings: settings,
|
settings: settings,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ class _MainNavigationScaffoldState extends State<MainNavigationScaffold> {
|
|||||||
return SessionScreen(
|
return SessionScreen(
|
||||||
sessionService: widget.sessionService,
|
sessionService: widget.sessionService,
|
||||||
firebaseService: widget.firebaseService,
|
firebaseService: widget.firebaseService,
|
||||||
|
nostrService: widget.nostrService,
|
||||||
onSessionChanged: _onSessionStateChanged,
|
onSessionChanged: _onSessionStateChanged,
|
||||||
);
|
);
|
||||||
case 4:
|
case 4:
|
||||||
|
|||||||
@@ -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<bool> 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.
|
/// Checks health of all relays by attempting to connect.
|
||||||
///
|
///
|
||||||
/// Updates relay connection status.
|
/// Updates relay connection status.
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import '../../data/nostr/models/nostr_relay.dart';
|
|||||||
|
|
||||||
/// Screen for managing Nostr relays.
|
/// Screen for managing Nostr relays.
|
||||||
///
|
///
|
||||||
/// Allows users to view, add, remove, and monitor relay health,
|
/// Allows users to view, add, remove, test, and toggle relays.
|
||||||
/// and trigger manual syncs.
|
|
||||||
class RelayManagementScreen extends StatefulWidget {
|
class RelayManagementScreen extends StatefulWidget {
|
||||||
/// Controller for managing relay state.
|
/// Controller for managing relay state.
|
||||||
final RelayManagementController controller;
|
final RelayManagementController controller;
|
||||||
@@ -22,6 +21,7 @@ class RelayManagementScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _RelayManagementScreenState extends State<RelayManagementScreen> {
|
class _RelayManagementScreenState extends State<RelayManagementScreen> {
|
||||||
final TextEditingController _urlController = TextEditingController();
|
final TextEditingController _urlController = TextEditingController();
|
||||||
|
final Map<String, bool> _testingRelays = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -29,6 +29,31 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -65,12 +90,48 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Actions section
|
// Top action buttons
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
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
|
// Add relay input
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -79,9 +140,7 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
|
|||||||
controller: _urlController,
|
controller: _urlController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Relay URL',
|
labelText: 'Relay URL',
|
||||||
hintText: widget.controller.relays.isNotEmpty
|
hintText: 'wss://relay.example.com',
|
||||||
? widget.controller.relays.first.url
|
|
||||||
: 'wss://nostrum.satoshinakamoto.win',
|
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.url,
|
keyboardType: TextInputType.url,
|
||||||
@@ -108,56 +167,6 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
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<RelayManagementScreen> {
|
|||||||
final relay = widget.controller.relays[index];
|
final relay = widget.controller.relays[index];
|
||||||
return _RelayListItem(
|
return _RelayListItem(
|
||||||
relay: relay,
|
relay: relay,
|
||||||
onConnect: () => widget.controller.connectRelay(relay.url),
|
isTesting: _testingRelays[relay.url] ?? false,
|
||||||
onDisconnect: () => widget.controller.disconnectRelay(relay.url),
|
onTest: () => _handleTestRelay(relay.url),
|
||||||
|
onToggle: () => widget.controller.toggleRelay(relay.url),
|
||||||
onRemove: () {
|
onRemove: () {
|
||||||
widget.controller.removeRelay(relay.url);
|
widget.controller.removeRelay(relay.url);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -227,19 +237,23 @@ class _RelayListItem extends StatelessWidget {
|
|||||||
/// The relay to display.
|
/// The relay to display.
|
||||||
final NostrRelay relay;
|
final NostrRelay relay;
|
||||||
|
|
||||||
/// Callback when connect is pressed.
|
/// Whether the relay is currently being tested.
|
||||||
final VoidCallback onConnect;
|
final bool isTesting;
|
||||||
|
|
||||||
/// Callback when disconnect is pressed.
|
/// Callback when test is pressed.
|
||||||
final VoidCallback onDisconnect;
|
final VoidCallback onTest;
|
||||||
|
|
||||||
|
/// Callback when toggle is pressed.
|
||||||
|
final VoidCallback onToggle;
|
||||||
|
|
||||||
/// Callback when remove is pressed.
|
/// Callback when remove is pressed.
|
||||||
final VoidCallback onRemove;
|
final VoidCallback onRemove;
|
||||||
|
|
||||||
const _RelayListItem({
|
const _RelayListItem({
|
||||||
required this.relay,
|
required this.relay,
|
||||||
required this.onConnect,
|
required this.isTesting,
|
||||||
required this.onDisconnect,
|
required this.onTest,
|
||||||
|
required this.onToggle,
|
||||||
required this.onRemove,
|
required this.onRemove,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -247,45 +261,107 @@ class _RelayListItem extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: ListTile(
|
child: Padding(
|
||||||
leading: CircleAvatar(
|
padding: const EdgeInsets.all(12),
|
||||||
backgroundColor: relay.isConnected ? Colors.green : Colors.grey,
|
child: Column(
|
||||||
child: Icon(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
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,
|
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
// Relay URL and status
|
||||||
icon: Icon(
|
Row(
|
||||||
relay.isConnected ? Icons.link_off : Icons.link,
|
children: [
|
||||||
color: relay.isConnected ? Colors.orange : Colors.green,
|
// Status indicator
|
||||||
|
Container(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: relay.isConnected
|
||||||
|
? Colors.green
|
||||||
|
: relay.isEnabled
|
||||||
|
? Colors.orange
|
||||||
|
: Colors.grey,
|
||||||
),
|
),
|
||||||
tooltip: relay.isConnected ? 'Disconnect' : 'Connect',
|
|
||||||
onPressed: relay.isConnected ? onDisconnect : onConnect,
|
|
||||||
),
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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(
|
IconButton(
|
||||||
icon: const Icon(Icons.delete, color: Colors.red),
|
icon: const Icon(Icons.delete, size: 20),
|
||||||
|
color: Colors.red,
|
||||||
tooltip: 'Remove',
|
tooltip: 'Remove',
|
||||||
onPressed: onRemove,
|
onPressed: onRemove,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../data/session/session_service.dart';
|
import '../../data/session/session_service.dart';
|
||||||
import '../../data/firebase/firebase_service.dart';
|
import '../../data/firebase/firebase_service.dart';
|
||||||
|
import '../../data/nostr/nostr_service.dart';
|
||||||
|
|
||||||
/// Screen for user session management (login/logout).
|
/// Screen for user session management (login/logout).
|
||||||
class SessionScreen extends StatefulWidget {
|
class SessionScreen extends StatefulWidget {
|
||||||
final SessionService? sessionService;
|
final SessionService? sessionService;
|
||||||
final FirebaseService? firebaseService;
|
final FirebaseService? firebaseService;
|
||||||
|
final NostrService? nostrService;
|
||||||
final VoidCallback? onSessionChanged;
|
final VoidCallback? onSessionChanged;
|
||||||
|
|
||||||
const SessionScreen({
|
const SessionScreen({
|
||||||
super.key,
|
super.key,
|
||||||
this.sessionService,
|
this.sessionService,
|
||||||
this.firebaseService,
|
this.firebaseService,
|
||||||
|
this.nostrService,
|
||||||
this.onSessionChanged,
|
this.onSessionChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -342,6 +345,17 @@ class _SessionScreenState extends State<SessionScreen> {
|
|||||||
const Divider(),
|
const Divider(),
|
||||||
const SizedBox(height: 12),
|
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('User ID: ${currentUser.id.substring(0, currentUser.id.length > 32 ? 32 : currentUser.id.length)}${currentUser.id.length > 32 ? '...' : ''}'),
|
||||||
Text('Username: ${currentUser.username}'),
|
Text('Username: ${currentUser.username}'),
|
||||||
Text(
|
Text(
|
||||||
@@ -494,3 +508,183 @@ class _SessionScreenState extends State<SessionScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<String> _preferredRelays = [];
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadPreferredRelays();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ void main() {
|
|||||||
databaseFactory = databaseFactoryFfi;
|
databaseFactory = databaseFactoryFfi;
|
||||||
|
|
||||||
late MockLocalStorageService mockLocalStorage;
|
late MockLocalStorageService mockLocalStorage;
|
||||||
late FirebaseService firebaseService;
|
|
||||||
late Directory tempDir;
|
late Directory tempDir;
|
||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ void main() {
|
|||||||
expect(find.textContaining('wss://relay2.example.com'), findsWidgets);
|
expect(find.textContaining('wss://relay2.example.com'), findsWidgets);
|
||||||
// Verify we have relay list items (Cards)
|
// Verify we have relay list items (Cards)
|
||||||
expect(find.byType(Card), findsNWidgets(2));
|
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',
|
testWidgets('adds relay when Add button is pressed',
|
||||||
@@ -157,16 +158,15 @@ void main() {
|
|||||||
testWidgets('displays check health button', (WidgetTester tester) async {
|
testWidgets('displays check health button', (WidgetTester tester) async {
|
||||||
await tester.pumpWidget(createTestWidget());
|
await tester.pumpWidget(createTestWidget());
|
||||||
|
|
||||||
expect(find.text('Check Health'), findsOneWidget);
|
expect(find.text('Test All'), findsOneWidget);
|
||||||
expect(find.byIcon(Icons.health_and_safety), findsOneWidget);
|
expect(find.byIcon(Icons.network_check), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('displays manual sync button when sync engine is configured',
|
testWidgets('displays toggle all button', (WidgetTester tester) async {
|
||||||
(WidgetTester tester) async {
|
|
||||||
await tester.pumpWidget(createTestWidget());
|
await tester.pumpWidget(createTestWidget());
|
||||||
|
|
||||||
expect(find.text('Manual Sync'), findsOneWidget);
|
expect(find.text('Turn All On'), findsOneWidget);
|
||||||
expect(find.byIcon(Icons.sync), findsOneWidget);
|
expect(find.byIcon(Icons.power_settings_new), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows loading state during health check',
|
testWidgets('shows loading state during health check',
|
||||||
@@ -175,9 +175,9 @@ void main() {
|
|||||||
await tester.pumpWidget(createTestWidget());
|
await tester.pumpWidget(createTestWidget());
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Tap check health button
|
// Tap test all button
|
||||||
final healthButton = find.text('Check Health');
|
final testAllButton = find.text('Test All');
|
||||||
await tester.tap(healthButton);
|
await tester.tap(testAllButton);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Check for loading indicator (may be brief)
|
// Check for loading indicator (may be brief)
|
||||||
@@ -231,12 +231,14 @@ void main() {
|
|||||||
await tester.pumpWidget(createTestWidget());
|
await tester.pumpWidget(createTestWidget());
|
||||||
await tester.pump();
|
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);
|
expect(find.textContaining('wss://relay.example.com'), findsWidgets);
|
||||||
// Verify status indicator is present
|
// Verify status indicator is present (now a Container with decoration, not CircleAvatar)
|
||||||
expect(find.byType(CircleAvatar), findsWidgets);
|
// The status indicator is a Container with BoxDecoration, so we check for the Card instead
|
||||||
// Verify we have a relay card
|
|
||||||
expect(find.byType(Card), findsWidgets);
|
expect(find.byType(Card), findsWidgets);
|
||||||
|
// Verify we have test button and toggle switch
|
||||||
|
expect(find.text('Test'), findsWidgets);
|
||||||
|
expect(find.byType(Switch), findsWidgets);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user