From f8aa20eb5c92cedf691ef22e5eab4731b3732e1f Mon Sep 17 00:00:00 2001 From: gitea Date: Thu, 6 Nov 2025 14:46:59 +0100 Subject: [PATCH] nostr tools added --- lib/data/nostr/models/nostr_event.dart | 98 ++++--- lib/data/nostr/models/nostr_keypair.dart | 109 +++++++- lib/data/nostr/models/nostr_profile.dart | 132 ++++++++++ lib/data/nostr/nostr_service.dart | 196 +++++++++++++- lib/data/session/models/user.dart | 13 + lib/data/session/session_service.dart | 74 ++++++ lib/main.dart | 3 +- lib/ui/nostr_events/nostr_events_screen.dart | 243 +++++++++++++++++- lib/ui/session/session_screen.dart | 132 +++++++++- pubspec.lock | 84 +++++- pubspec.yaml | 3 +- test/data/immich/immich_service_test.dart | 96 ++++--- .../main_navigation_scaffold_test.dart | 17 +- .../main_navigation_scaffold_test.mocks.dart | 87 +++++-- 14 files changed, 1146 insertions(+), 141 deletions(-) create mode 100644 lib/data/nostr/models/nostr_profile.dart diff --git a/lib/data/nostr/models/nostr_event.dart b/lib/data/nostr/models/nostr_event.dart index 74cf53f..ace1355 100644 --- a/lib/data/nostr/models/nostr_event.dart +++ b/lib/data/nostr/models/nostr_event.dart @@ -1,5 +1,4 @@ -import 'dart:convert'; -import 'package:crypto/crypto.dart'; +import 'package:nostr_tools/nostr_tools.dart'; /// Represents a Nostr event. class NostrEvent { @@ -24,6 +23,10 @@ class NostrEvent { /// Event signature (64-byte hex string). final String sig; + /// Event API instance for event operations. + static final _eventApi = EventApi(); + static final _keyApi = KeyApi(); + /// Creates a [NostrEvent] with the provided values. NostrEvent({ required this.id, @@ -50,6 +53,33 @@ class NostrEvent { ); } + /// Creates a [NostrEvent] from a nostr_tools Event object. + factory NostrEvent.fromNostrToolsEvent(Event event) { + return NostrEvent( + id: event.id, + pubkey: event.pubkey, + createdAt: event.created_at, + kind: event.kind, + tags: event.tags, + content: event.content, + sig: event.sig, + ); + } + + /// Converts this [NostrEvent] to a nostr_tools Event object. + Event toNostrToolsEvent() { + return Event( + id: id, + pubkey: pubkey, + created_at: createdAt, + kind: kind, + tags: tags, + content: content, + sig: sig, + verify: false, // Already verified if coming from our model + ); + } + /// Converts the [NostrEvent] to a JSON array (Nostr event format). List toJson() { return [ @@ -63,7 +93,7 @@ class NostrEvent { ]; } - /// Creates an event from content and signs it with a private key. + /// Creates an event from content and signs it with a private key using nostr_tools. /// /// [content] - Event content. /// [kind] - Event kind (default: 1 for text note). @@ -75,51 +105,42 @@ class NostrEvent { required String privateKey, List>? tags, }) { - // Derive public key from private key (simplified) - final privateKeyBytes = _hexToBytes(privateKey); - final publicKeyBytes = sha256.convert(privateKeyBytes).bytes.sublist(0, 32); - final pubkey = publicKeyBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); - + // Get public key from private key using nostr_tools + final pubkey = _keyApi.getPublicKey(privateKey); + final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; final eventTags = tags ?? []; - // Create event data for signing - final eventData = [ - 0, - pubkey, - createdAt, - kind, - eventTags, - content, - ]; - - // Generate event ID (hash of event data) - final eventJson = jsonEncode(eventData); - final idBytes = sha256.convert(utf8.encode(eventJson)).bytes.sublist(0, 32); - final id = idBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); - - // Generate signature (simplified - in real Nostr, use secp256k1) - final sigBytes = sha256.convert(utf8.encode(id + privateKey)).bytes.sublist(0, 32); - final sig = sigBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); - - return NostrEvent( - id: id, - pubkey: pubkey, - createdAt: createdAt, + // Create event using nostr_tools Event model + final event = Event( kind: kind, tags: eventTags, content: content, - sig: sig, + created_at: createdAt, + pubkey: pubkey, + verify: false, // We'll sign it next ); + + // Get event hash (ID) using nostr_tools + event.id = _eventApi.getEventHash(event); + + // Sign the event using nostr_tools + event.sig = _eventApi.signEvent(event, privateKey); + + // Convert to our NostrEvent model + return NostrEvent.fromNostrToolsEvent(event); } - /// Converts hex string to bytes. - static List _hexToBytes(String hex) { - final result = []; - for (int i = 0; i < hex.length; i += 2) { - result.add(int.parse(hex.substring(i, i + 2), radix: 16)); + /// Verifies the event signature using nostr_tools. + /// + /// Returns true if the signature is valid, false otherwise. + bool verifySignature() { + try { + final event = toNostrToolsEvent(); + return _eventApi.verifySignature(event); + } catch (e) { + return false; } - return result; } @override @@ -127,4 +148,3 @@ class NostrEvent { return 'NostrEvent(id: ${id.substring(0, 8)}..., kind: $kind, content: ${content.substring(0, content.length > 20 ? 20 : content.length)}...)'; } } - diff --git a/lib/data/nostr/models/nostr_keypair.dart b/lib/data/nostr/models/nostr_keypair.dart index bd6fbf8..92e5511 100644 --- a/lib/data/nostr/models/nostr_keypair.dart +++ b/lib/data/nostr/models/nostr_keypair.dart @@ -1,5 +1,4 @@ -import 'dart:convert'; -import 'package:crypto/crypto.dart'; +import 'package:nostr_tools/nostr_tools.dart'; /// Represents a Nostr keypair (private and public keys). class NostrKeyPair { @@ -9,6 +8,12 @@ class NostrKeyPair { /// Public key in hex format (32 bytes, 64 hex characters). final String publicKey; + /// Key API instance for key operations. + static final _keyApi = KeyApi(); + + /// NIP-19 API instance for bech32 encoding/decoding. + static final _nip19 = Nip19(); + /// Creates a [NostrKeyPair] with the provided keys. /// /// [privateKey] - Private key in hex format. @@ -18,21 +23,87 @@ class NostrKeyPair { required this.publicKey, }); - /// Generates a new Nostr keypair. + /// Generates a new Nostr keypair using nostr_tools. /// /// Returns a new [NostrKeyPair] with random private and public keys. factory NostrKeyPair.generate() { - // Generate random 32-byte private key - final random = List.generate(32, (i) => DateTime.now().microsecondsSinceEpoch % 256); - final privateKeyBytes = sha256.convert(utf8.encode(DateTime.now().toString() + random.toString())).bytes.sublist(0, 32); - final privateKey = privateKeyBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + final privateKey = _keyApi.generatePrivateKey(); + final publicKey = _keyApi.getPublicKey(privateKey); + + return NostrKeyPair( + privateKey: privateKey, + publicKey: publicKey, + ); + } - // Derive public key from private key (simplified - in real Nostr, use secp256k1) - final publicKeyBytes = sha256.convert(privateKeyBytes).bytes.sublist(0, 32); - final publicKey = publicKeyBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + /// Creates a [NostrKeyPair] from nsec (private key in bech32 format). + /// + /// [nsec] - Private key in nsec format (e.g., 'nsec1...'). + /// + /// Returns a [NostrKeyPair] with the decoded private key and derived public key. + /// + /// Throws [FormatException] if nsec is invalid. + factory NostrKeyPair.fromNsec(String nsec) { + try { + final decoded = _nip19.decode(nsec); + if (decoded['type'] != 'nsec') { + throw FormatException('Invalid nsec format: expected "nsec" type'); + } + + final privateKey = decoded['data'] as String; + final publicKey = _keyApi.getPublicKey(privateKey); + + return NostrKeyPair( + privateKey: privateKey, + publicKey: publicKey, + ); + } catch (e) { + throw FormatException('Failed to parse nsec: $e'); + } + } + /// Creates a [NostrKeyPair] from npub (public key in bech32 format). + /// + /// Note: This creates a keypair with only the public key. Private key operations won't work. + /// + /// [npub] - Public key in npub format (e.g., 'npub1...'). + /// + /// Returns a [NostrKeyPair] with the decoded public key and empty private key. + /// + /// Throws [FormatException] if npub is invalid. + factory NostrKeyPair.fromNpub(String npub) { + try { + final decoded = _nip19.decode(npub); + if (decoded['type'] != 'npub') { + throw FormatException('Invalid npub format: expected "npub" type'); + } + + final publicKey = decoded['data'] as String; + + // No private key available when importing from npub + return NostrKeyPair( + privateKey: '', // Empty private key - can't sign events + publicKey: publicKey, + ); + } catch (e) { + throw FormatException('Failed to parse npub: $e'); + } + } + + /// Creates a [NostrKeyPair] from a hex private key. + /// + /// [hexPrivateKey] - Private key in hex format (64 hex characters). + /// + /// Returns a [NostrKeyPair] with the provided private key and derived public key. + factory NostrKeyPair.fromHexPrivateKey(String hexPrivateKey) { + if (hexPrivateKey.length != 64) { + throw FormatException('Invalid hex private key: expected 64 hex characters'); + } + + final publicKey = _keyApi.getPublicKey(hexPrivateKey); + return NostrKeyPair( - privateKey: privateKey, + privateKey: hexPrivateKey, publicKey: publicKey, ); } @@ -53,9 +124,21 @@ class NostrKeyPair { }; } + /// Encodes the private key to nsec format. + String toNsec() { + if (privateKey.isEmpty) { + throw StateError('Cannot encode empty private key to nsec'); + } + return _nip19.nsecEncode(privateKey); + } + + /// Encodes the public key to npub format. + String toNpub() { + return _nip19.npubEncode(publicKey); + } + @override String toString() { - return 'NostrKeyPair(publicKey: ${publicKey.substring(0, 8)}...)'; + return 'NostrKeyPair(publicKey: $publicKey)'; } } - diff --git a/lib/data/nostr/models/nostr_profile.dart b/lib/data/nostr/models/nostr_profile.dart new file mode 100644 index 0000000..5aebee2 --- /dev/null +++ b/lib/data/nostr/models/nostr_profile.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; + +/// Represents a Nostr user profile (metadata from kind 0 events). +class NostrProfile { + /// Public key (npub or hex). + final String publicKey; + + /// Display name or username. + final String? name; + + /// About/bio text. + final String? about; + + /// Profile picture URL. + final String? picture; + + /// Website URL. + final String? website; + + /// NIP-05 identifier (e.g., user@domain.com). + final String? nip05; + + /// Banner image URL. + final String? banner; + + /// LUD16 (Lightning address). + final String? lud16; + + /// Raw metadata JSON for additional fields. + final Map rawMetadata; + + /// Timestamp when profile was last updated. + final DateTime? updatedAt; + + /// Creates a [NostrProfile] instance. + NostrProfile({ + required this.publicKey, + this.name, + this.about, + this.picture, + this.website, + this.nip05, + this.banner, + this.lud16, + Map? rawMetadata, + this.updatedAt, + }) : rawMetadata = rawMetadata ?? {}; + + /// Creates a [NostrProfile] from Nostr metadata event content (JSON string). + /// + /// [publicKey] - The public key of the profile owner. + /// [content] - JSON string from kind 0 event content. + /// [updatedAt] - Optional timestamp when profile was updated. + factory NostrProfile.fromEventContent({ + required String publicKey, + required String content, + DateTime? updatedAt, + }) { + try { + final metadata = jsonDecode(content) as Map; + + return NostrProfile( + publicKey: publicKey, + name: metadata['name'] as String?, + about: metadata['about'] as String?, + picture: metadata['picture'] as String?, + website: metadata['website'] as String?, + nip05: metadata['nip05'] as String?, + banner: metadata['banner'] as String?, + lud16: metadata['lud16'] as String?, + rawMetadata: metadata, + updatedAt: updatedAt ?? DateTime.now(), + ); + } catch (e) { + // Return minimal profile if parsing fails + return NostrProfile( + publicKey: publicKey, + rawMetadata: {}, + updatedAt: updatedAt ?? DateTime.now(), + ); + } + } + + /// Creates a [NostrProfile] from a JSON map. + factory NostrProfile.fromJson(Map json) { + return NostrProfile( + publicKey: json['publicKey'] as String, + name: json['name'] as String?, + about: json['about'] as String?, + picture: json['picture'] as String?, + website: json['website'] as String?, + nip05: json['nip05'] as String?, + banner: json['banner'] as String?, + lud16: json['lud16'] as String?, + rawMetadata: json['rawMetadata'] as Map? ?? {}, + updatedAt: json['updatedAt'] != null + ? DateTime.parse(json['updatedAt'] as String) + : null, + ); + } + + /// Converts [NostrProfile] to JSON. + Map toJson() { + return { + 'publicKey': publicKey, + 'name': name, + 'about': about, + 'picture': picture, + 'website': website, + 'nip05': nip05, + 'banner': banner, + 'lud16': lud16, + 'rawMetadata': rawMetadata, + 'updatedAt': updatedAt?.toIso8601String(), + }; + } + + /// Gets display name (name, nip05, or public key prefix). + String get displayName { + if (name != null && name!.isNotEmpty) return name!; + if (nip05 != null && nip05!.isNotEmpty) return nip05!; + return publicKey.length > 16 + ? '${publicKey.substring(0, 8)}...${publicKey.substring(publicKey.length - 8)}' + : publicKey; + } + + @override + String toString() { + return 'NostrProfile(publicKey: ${publicKey.substring(0, 8)}..., name: $name)'; + } +} + diff --git a/lib/data/nostr/nostr_service.dart b/lib/data/nostr/nostr_service.dart index 129a870..a3b8df4 100644 --- a/lib/data/nostr/nostr_service.dart +++ b/lib/data/nostr/nostr_service.dart @@ -1,9 +1,12 @@ import 'dart:async'; 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 'models/nostr_keypair.dart'; import 'models/nostr_event.dart'; import 'models/nostr_relay.dart'; +import 'models/nostr_profile.dart'; /// Exception thrown when Nostr operations fail. class NostrException implements Exception { @@ -104,11 +107,31 @@ class NostrService { (message) { try { final data = jsonDecode(message as String); - if (data is List) { - controller.add({ - 'type': data[0] as String, - 'data': data.length > 1 ? data[1] : null, - }); + if (data is List && data.isNotEmpty) { + final messageType = data[0] as String; + if (messageType == 'EVENT' && data.length >= 3) { + // EVENT format: ["EVENT", , ] + // event_json can be either a JSON object or array format + final eventData = data[2]; + controller.add({ + 'type': 'EVENT', + 'subscription_id': data[1], + 'data': eventData, + }); + } else if (messageType == 'EOSE' && data.length >= 2) { + // EOSE format: ["EOSE", ] + controller.add({ + 'type': 'EOSE', + 'subscription_id': data[1], + 'data': null, + }); + } else { + // Other message types + controller.add({ + 'type': messageType, + 'data': data.length > 1 ? data[1] : null, + }); + } } } catch (e) { // Ignore invalid messages @@ -165,8 +188,12 @@ class NostrService { throw NostrException('Not connected to relay: $relayUrl'); } + // Convert to nostr_tools Event and then to JSON + final nostrToolsEvent = event.toNostrToolsEvent(); + final eventJson = nostrToolsEvent.toJson(); + // Send event in Nostr format: ["EVENT", ] - final message = jsonEncode(['EVENT', event.toJson()]); + final message = jsonEncode(['EVENT', eventJson]); channel.sink.add(message); } catch (e) { throw NostrException('Failed to publish event: $e'); @@ -229,6 +256,163 @@ class NostrService { } } + /// Fetches user profile (kind 0 metadata event) from relays. + /// + /// [publicKey] - The public key (hex format) of the user. + /// [timeout] - Timeout for the request (default: 10 seconds). + /// + /// Returns [NostrProfile] if found, null otherwise. + /// + /// Throws [NostrException] if fetch fails. + Future fetchProfile(String publicKey, {Duration timeout = const Duration(seconds: 10)}) async { + if (_relays.isEmpty) { + throw NostrException('No relays configured'); + } + + // Try to fetch from connected relays first + for (final relay in _relays) { + if (relay.isConnected) { + try { + final profile = await _fetchProfileFromRelay(publicKey, relay.url, timeout); + if (profile != null) { + return profile; + } + } catch (e) { + // Continue to next relay + continue; + } + } + } + + // If no connected relays or all failed, try connecting to first relay + if (_relays.isNotEmpty) { + try { + final firstRelay = _relays.first; + if (!firstRelay.isConnected) { + await connectRelay(firstRelay.url).timeout(timeout); + } + return await _fetchProfileFromRelay(publicKey, firstRelay.url, timeout); + } catch (e) { + throw NostrException('Failed to fetch profile: $e'); + } + } + + return null; + } + + /// Fetches profile from a specific relay. + Future _fetchProfileFromRelay(String publicKey, String relayUrl, Duration timeout) async { + final channel = _connections[relayUrl]; + if (channel == null) { + return null; + } + + // Send REQ message to request kind 0 events for this public key + // Nostr REQ format: ["REQ", , ] + final reqId = 'profile_${DateTime.now().millisecondsSinceEpoch}'; + + final completer = Completer(); + final subscription = _messageControllers[relayUrl]?.stream.listen( + (message) { + // Message format from connectRelay: + // {'type': 'EVENT', 'subscription_id': , 'data': } + // or {'type': 'EOSE', 'subscription_id': , 'data': null} + if (message['type'] == 'EVENT' && + message['subscription_id'] == reqId && + message['data'] != null) { + try { + final eventData = message['data']; + Event nostrToolsEvent; + + // Handle both JSON object and array formats + if (eventData is Map) { + // JSON object format + nostrToolsEvent = Event( + id: eventData['id'] as String? ?? '', + pubkey: eventData['pubkey'] as String? ?? '', + created_at: eventData['created_at'] as int? ?? 0, + kind: eventData['kind'] as int? ?? 0, + tags: (eventData['tags'] as List?) + ?.map((tag) => (tag as List).map((e) => e.toString()).toList()) + .toList() ?? [], + content: eventData['content'] as String? ?? '', + sig: eventData['sig'] as String? ?? '', + verify: false, // Skip verification for profile fetching + ); + } else if (eventData is List && eventData.length >= 7) { + // Array format: [id, pubkey, created_at, kind, tags, content, sig] + nostrToolsEvent = Event( + id: eventData[0] as String? ?? '', + pubkey: eventData[1] as String? ?? '', + created_at: eventData[2] as int? ?? 0, + kind: eventData[3] as int? ?? 0, + tags: (eventData[4] as List?) + ?.map((tag) => (tag as List).map((e) => e.toString()).toList()) + .toList() ?? [], + content: eventData[5] as String? ?? '', + sig: eventData[6] as String? ?? '', + verify: false, // Skip verification for profile fetching + ); + } else { + return; // Invalid format + } + + // Convert to our NostrEvent model + final event = NostrEvent.fromNostrToolsEvent(nostrToolsEvent); + + // Check if it's a kind 0 (metadata) event for this public key + if (event.kind == 0 && event.pubkey.toLowerCase() == publicKey.toLowerCase()) { + final profile = NostrProfile.fromEventContent( + publicKey: publicKey, + content: event.content, + updatedAt: DateTime.fromMillisecondsSinceEpoch(event.createdAt * 1000), + ); + if (!completer.isCompleted) { + completer.complete(profile); + } + } + } catch (e) { + // Ignore parsing errors + debugPrint('Error parsing profile event: $e'); + } + } else if (message['type'] == 'EOSE' && + message['subscription_id'] == reqId) { + // End of stored events - no profile found + if (!completer.isCompleted) { + completer.complete(null); + } + } + }, + onError: (error) { + if (!completer.isCompleted) { + completer.completeError(error); + } + }, + ); + + // Send REQ message to request kind 0 events for this public key + final reqMessage = jsonEncode([ + 'REQ', + reqId, + { + 'authors': [publicKey], + 'kinds': [0], + 'limit': 1, + } + ]); + + channel.sink.add(reqMessage); + + try { + final profile = await completer.future.timeout(timeout); + subscription?.cancel(); + return profile; + } catch (e) { + subscription?.cancel(); + return null; + } + } + /// Closes all connections and cleans up resources. void dispose() { for (final relayUrl in _connections.keys.toList()) { diff --git a/lib/data/session/models/user.dart b/lib/data/session/models/user.dart index 6ec3f8f..50f9ed9 100644 --- a/lib/data/session/models/user.dart +++ b/lib/data/session/models/user.dart @@ -1,3 +1,5 @@ +import '../../nostr/models/nostr_profile.dart'; + /// Data model representing a user session. /// /// This model stores user identification and authentication information @@ -15,17 +17,22 @@ class User { /// Timestamp when the session was created (milliseconds since epoch). final int createdAt; + /// Optional Nostr profile data (if logged in via Nostr). + final NostrProfile? nostrProfile; + /// Creates a [User] instance. /// /// [id] - Unique identifier for the user. /// [username] - Display name or username. /// [token] - Optional authentication token. /// [createdAt] - Session creation timestamp (defaults to current time). + /// [nostrProfile] - Optional Nostr profile data. User({ required this.id, required this.username, this.token, int? createdAt, + this.nostrProfile, }) : createdAt = createdAt ?? DateTime.now().millisecondsSinceEpoch; /// Creates a [User] from a Map (e.g., from database or JSON). @@ -35,6 +42,9 @@ class User { username: map['username'] as String, token: map['token'] as String?, createdAt: map['created_at'] as int?, + nostrProfile: map['nostr_profile'] != null + ? NostrProfile.fromJson(map['nostr_profile'] as Map) + : null, ); } @@ -45,6 +55,7 @@ class User { 'username': username, 'token': token, 'created_at': createdAt, + 'nostr_profile': nostrProfile?.toJson(), }; } @@ -54,12 +65,14 @@ class User { String? username, String? token, int? createdAt, + NostrProfile? nostrProfile, }) { return User( id: id ?? this.id, username: username ?? this.username, token: token ?? this.token, createdAt: createdAt ?? this.createdAt, + nostrProfile: nostrProfile ?? this.nostrProfile, ); } diff --git a/lib/data/session/session_service.dart b/lib/data/session/session_service.dart index d2485fe..5f64c14 100644 --- a/lib/data/session/session_service.dart +++ b/lib/data/session/session_service.dart @@ -5,6 +5,9 @@ import 'package:path_provider/path_provider.dart'; import '../local/local_storage_service.dart'; import '../sync/sync_engine.dart'; import '../firebase/firebase_service.dart'; +import '../nostr/nostr_service.dart'; +import '../nostr/models/nostr_keypair.dart'; +import '../nostr/models/nostr_profile.dart'; import 'models/user.dart'; /// Exception thrown when session operations fail. @@ -42,6 +45,9 @@ class SessionService { /// Firebase service for optional cloud sync (optional). final FirebaseService? _firebaseService; + /// Nostr service for Nostr authentication (optional). + final NostrService? _nostrService; + /// Map of user IDs to their session storage paths. final Map _userDbPaths = {}; @@ -59,17 +65,20 @@ class SessionService { /// [localStorage] - Local storage service for data persistence. /// [syncEngine] - Optional sync engine for coordinating sync operations. /// [firebaseService] - Optional Firebase service for cloud sync. + /// [nostrService] - Optional Nostr service for Nostr authentication. /// [testDbPath] - Optional database path for testing. /// [testCacheDir] - Optional cache directory for testing. SessionService({ required LocalStorageService localStorage, SyncEngine? syncEngine, FirebaseService? firebaseService, + NostrService? nostrService, String? testDbPath, Directory? testCacheDir, }) : _localStorage = localStorage, _syncEngine = syncEngine, _firebaseService = firebaseService, + _nostrService = nostrService, _testDbPath = testDbPath, _testCacheDir = testCacheDir; @@ -128,6 +137,71 @@ class SessionService { } } + /// Logs in a user using Nostr key (nsec or npub). + /// + /// [nsecOrNpub] - Nostr key in nsec (private) or npub (public) format. + /// + /// Returns the logged-in [User] with fetched profile data. + /// + /// Throws [SessionException] if login fails or if user is already logged in. + Future loginWithNostr(String nsecOrNpub) async { + if (_currentUser != null) { + throw SessionException('User already logged in. Logout first.'); + } + + if (_nostrService == null) { + throw SessionException('Nostr service not available'); + } + + try { + // Parse the key + NostrKeyPair keyPair; + if (nsecOrNpub.startsWith('nsec')) { + keyPair = NostrKeyPair.fromNsec(nsecOrNpub); + } else if (nsecOrNpub.startsWith('npub')) { + keyPair = NostrKeyPair.fromNpub(nsecOrNpub); + } else { + throw SessionException('Invalid Nostr key format. Expected nsec or npub.'); + } + + // Fetch profile from relays + NostrProfile? profile; + try { + profile = await _nostrService!.fetchProfile(keyPair.publicKey); + } catch (e) { + debugPrint('Warning: Failed to fetch Nostr profile: $e'); + // Continue without profile - offline-first behavior + } + + // Create user with Nostr profile + final user = User( + id: keyPair.publicKey, + username: profile?.displayName ?? keyPair.publicKey.substring(0, 16), + nostrProfile: profile, + ); + + // Create user-specific storage paths + await _setupUserStorage(user); + + // Sync with Firebase if enabled + if (_firebaseService != null && _firebaseService!.isEnabled) { + try { + await _firebaseService!.syncItemsFromFirestore(user.id); + } catch (e) { + // Log error but don't fail login - offline-first behavior + debugPrint('Warning: Failed to sync from Firebase on login: $e'); + } + } + + // Set as current user + _currentUser = user; + + return user; + } catch (e) { + throw SessionException('Failed to login with Nostr: $e'); + } + } + /// Logs out the current user and clears session data. /// /// [clearCache] - Whether to clear cached data (default: true). diff --git a/lib/main.dart b/lib/main.dart index 4aff098..0f93674 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -117,11 +117,12 @@ class _MyAppState extends State { } } - // Initialize SessionService with Firebase integration + // Initialize SessionService with Firebase and Nostr integration _sessionService = SessionService( localStorage: _storageService!, syncEngine: _syncEngine, firebaseService: _firebaseService, + nostrService: _nostrService, ); setState(() { diff --git a/lib/ui/nostr_events/nostr_events_screen.dart b/lib/ui/nostr_events/nostr_events_screen.dart index e9ae1fc..7e8cc15 100644 --- a/lib/ui/nostr_events/nostr_events_screen.dart +++ b/lib/ui/nostr_events/nostr_events_screen.dart @@ -26,6 +26,10 @@ class _NostrEventsScreenState extends State { Map _connectionStatus = {}; List _events = []; bool _isLoading = false; + final TextEditingController _nsecController = TextEditingController(); + final TextEditingController _npubController = TextEditingController(); + final TextEditingController _hexPrivateKeyController = TextEditingController(); + bool _showImportFields = false; @override void initState() { @@ -34,6 +38,14 @@ class _NostrEventsScreenState extends State { _generateKeyPair(); } + @override + void dispose() { + _nsecController.dispose(); + _npubController.dispose(); + _hexPrivateKeyController.dispose(); + super.dispose(); + } + void _loadRelays() { if (widget.nostrService == null) return; @@ -50,9 +62,115 @@ class _NostrEventsScreenState extends State { setState(() { _keyPair = widget.nostrService!.generateKeyPair(); + _nsecController.clear(); + _npubController.clear(); + _hexPrivateKeyController.clear(); + _showImportFields = false; }); } + void _importFromNsec() { + final nsec = _nsecController.text.trim(); + if (nsec.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please enter an nsec key'), + backgroundColor: Colors.orange, + ), + ); + return; + } + + try { + setState(() { + _keyPair = NostrKeyPair.fromNsec(nsec); + _showImportFields = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Keypair imported from nsec'), + backgroundColor: Colors.green, + ), + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to import nsec: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } + + void _importFromNpub() { + final npub = _npubController.text.trim(); + if (npub.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please enter an npub key'), + backgroundColor: Colors.orange, + ), + ); + return; + } + + try { + setState(() { + _keyPair = NostrKeyPair.fromNpub(npub); + _showImportFields = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Public key imported from npub (note: cannot sign events without private key)'), + backgroundColor: Colors.orange, + ), + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to import npub: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } + + void _importFromHexPrivateKey() { + final hexKey = _hexPrivateKeyController.text.trim(); + if (hexKey.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please enter a hex private key'), + backgroundColor: Colors.orange, + ), + ); + return; + } + + try { + setState(() { + _keyPair = NostrKeyPair.fromHexPrivateKey(hexKey); + _showImportFields = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Keypair imported from hex private key'), + backgroundColor: Colors.green, + ), + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to import hex key: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } + Future _connectToRelay(String relayUrl) async { if (widget.nostrService == null) return; @@ -149,6 +267,21 @@ class _NostrEventsScreenState extends State { _isLoading = true; }); + if (_keyPair!.privateKey.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Cannot publish: No private key available. Import nsec or hex private key.'), + backgroundColor: Colors.red, + ), + ); + } + setState(() { + _isLoading = false; + }); + return; + } + try { // Create a test event final event = NostrEvent.create( @@ -237,28 +370,112 @@ class _NostrEventsScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Keypair', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Keypair', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + TextButton.icon( + onPressed: () { + setState(() { + _showImportFields = !_showImportFields; + }); + }, + icon: Icon(_showImportFields ? Icons.visibility_off : Icons.import_export), + label: Text(_showImportFields ? 'Hide Import' : 'Import Key'), + ), + ], ), const SizedBox(height: 12), if (_keyPair != null) ...[ - Text( - 'Public Key: ${_keyPair!.publicKey.substring(0, 16)}...', - style: const TextStyle(fontSize: 12, fontFamily: 'monospace'), + const Text( + 'Public Key:', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold), ), - const SizedBox(height: 4), - Text( - 'Private Key: ${_keyPair!.privateKey.substring(0, 16)}...', - style: const TextStyle(fontSize: 12, fontFamily: 'monospace'), + SelectableText( + _keyPair!.publicKey, + style: const TextStyle(fontSize: 11, fontFamily: 'monospace'), ), + const SizedBox(height: 8), + if (_keyPair!.privateKey.isNotEmpty) ...[ + const Text( + 'Private Key:', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold), + ), + SelectableText( + _keyPair!.privateKey, + style: const TextStyle(fontSize: 11, fontFamily: 'monospace'), + ), + ] else ...[ + const Text( + 'Private Key: (not available - imported from npub only)', + style: TextStyle(fontSize: 12, color: Colors.orange), + ), + ], ] else ...[ const Text('No keypair generated'), ], const SizedBox(height: 12), + if (_showImportFields) ...[ + const Divider(), + const SizedBox(height: 8), + const Text( + 'Import Key', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + TextField( + controller: _nsecController, + decoration: const InputDecoration( + labelText: 'nsec (private key)', + hintText: 'nsec1...', + border: OutlineInputBorder(), + helperText: 'Enter your nsec private key', + ), + ), + const SizedBox(height: 8), + ElevatedButton( + onPressed: _importFromNsec, + child: const Text('Import from nsec'), + ), + const SizedBox(height: 16), + TextField( + controller: _npubController, + decoration: const InputDecoration( + labelText: 'npub (public key)', + hintText: 'npub1...', + border: OutlineInputBorder(), + helperText: 'Enter npub to view only (cannot sign events)', + ), + ), + const SizedBox(height: 8), + ElevatedButton( + onPressed: _importFromNpub, + child: const Text('Import from npub'), + ), + const SizedBox(height: 16), + TextField( + controller: _hexPrivateKeyController, + decoration: const InputDecoration( + labelText: 'Hex Private Key', + hintText: '64 hex characters', + border: OutlineInputBorder(), + helperText: 'Enter private key in hex format (64 characters)', + ), + ), + const SizedBox(height: 8), + ElevatedButton( + onPressed: _importFromHexPrivateKey, + child: const Text('Import from Hex'), + ), + const Divider(), + const SizedBox(height: 8), + ], ElevatedButton.icon( onPressed: _generateKeyPair, icon: const Icon(Icons.refresh), diff --git a/lib/ui/session/session_screen.dart b/lib/ui/session/session_screen.dart index e749af7..1eb9496 100644 --- a/lib/ui/session/session_screen.dart +++ b/lib/ui/session/session_screen.dart @@ -24,8 +24,10 @@ class _SessionScreenState extends State { final TextEditingController _userIdController = TextEditingController(); final TextEditingController _emailController = TextEditingController(); final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _nostrKeyController = TextEditingController(); bool _isLoading = false; bool _useFirebaseAuth = false; + bool _useNostrLogin = false; @override void initState() { @@ -41,6 +43,7 @@ class _SessionScreenState extends State { _userIdController.dispose(); _emailController.dispose(); _passwordController.dispose(); + _nostrKeyController.dispose(); super.dispose(); } @@ -52,6 +55,50 @@ class _SessionScreenState extends State { }); try { + // Handle Nostr login + if (_useNostrLogin) { + final nostrKey = _nostrKeyController.text.trim(); + + if (nostrKey.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please enter nsec or npub key'), + ), + ); + } + return; + } + + // Validate format + if (!nostrKey.startsWith('nsec') && !nostrKey.startsWith('npub')) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Invalid key format. Expected nsec or npub.'), + backgroundColor: Colors.red, + ), + ); + } + return; + } + + // Login with Nostr + await widget.sessionService!.loginWithNostr(nostrKey); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Nostr login successful'), + ), + ); + setState(() {}); + widget.onSessionChanged?.call(); + } + return; + } + + // Handle Firebase or regular login if (_useFirebaseAuth && widget.firebaseService != null) { // Use Firebase Auth for authentication final email = _emailController.text.trim(); @@ -246,7 +293,56 @@ class _SessionScreenState extends State { ), ), const SizedBox(height: 12), - Text('User ID: ${currentUser.id}'), + // Display Nostr profile if available + if (currentUser.nostrProfile != null) ...[ + Row( + children: [ + if (currentUser.nostrProfile!.picture != null) + CircleAvatar( + radius: 30, + backgroundImage: NetworkImage( + currentUser.nostrProfile!.picture!, + ), + onBackgroundImageError: (_, __) {}, + ) + else + const CircleAvatar( + radius: 30, + child: Icon(Icons.person), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + currentUser.nostrProfile!.name ?? + currentUser.nostrProfile!.displayName, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + if (currentUser.nostrProfile!.about != null) + Text( + currentUser.nostrProfile!.about!, + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + const Divider(), + 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( 'Created: ${DateTime.fromMillisecondsSinceEpoch(currentUser.createdAt).toString().split('.')[0]}', @@ -306,7 +402,39 @@ class _SessionScreenState extends State { ), ], const SizedBox(height: 24), - if (_useFirebaseAuth) ...[ + // Login method selector + SegmentedButton( + segments: const [ + ButtonSegment( + value: false, + label: Text('Regular'), + ), + ButtonSegment( + value: true, + label: Text('Nostr'), + ), + ], + selected: {_useNostrLogin}, + onSelectionChanged: (Set newSelection) { + setState(() { + _useNostrLogin = newSelection.first; + }); + }, + ), + const SizedBox(height: 24), + if (_useNostrLogin) ...[ + TextField( + controller: _nostrKeyController, + decoration: const InputDecoration( + labelText: 'Nostr Key (nsec or npub)', + hintText: 'Enter your nsec or npub key', + border: OutlineInputBorder(), + helperText: 'Enter your Nostr private key (nsec) or public key (npub)', + ), + maxLines: 3, + minLines: 1, + ), + ] else if (_useFirebaseAuth) ...[ TextField( controller: _emailController, decoration: const InputDecoration( diff --git a/pubspec.lock b/pubspec.lock index 5ccd9c5..5861020 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + base58check: + dependency: transitive + description: + name: base58check + sha256: "6c300dfc33e598d2fe26319e13f6243fea81eaf8204cb4c6b69ef20a625319a5" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + bech32: + dependency: transitive + description: + name: bech32 + sha256: "156cbace936f7720c79a79d16a03efad343b1ef17106716e04b8b8e39f99f7f7" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + bip32: + dependency: transitive + description: + name: bip32 + sha256: "54787cd7a111e9d37394aabbf53d1fc5e2e0e0af2cd01c459147a97c0e3f8a97" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + bip340: + dependency: transitive + description: + name: bip340 + sha256: "2a92f6ed68959f75d67c9a304c17928b9c9449587d4f75ee68f34152f7f69e87" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + bip39: + dependency: transitive + description: + name: bip39 + sha256: de1ee27ebe7d96b84bb3a04a4132a0a3007dcdd5ad27dd14aa87a29d97c45edc + url: "https://pub.dev" + source: hosted + version: "1.0.6" bloc: dependency: transitive description: @@ -65,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + bs58check: + dependency: transitive + description: + name: bs58check + sha256: c4a164d42b25c2f6bc88a8beccb9fc7d01440f3c60ba23663a20a70faf484ea9 + url: "https://pub.dev" + source: hosted + version: "1.0.2" build: dependency: transitive description: @@ -218,7 +266,7 @@ packages: source: hosted version: "1.15.0" crypto: - dependency: "direct main" + dependency: transitive description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -456,6 +504,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + hex: + dependency: transitive + description: + name: hex + sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" + url: "https://pub.dev" + source: hosted + version: "0.2.0" http: dependency: "direct main" description: @@ -504,6 +560,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.9.0" + kepler: + dependency: transitive + description: + name: kepler + sha256: "8cf9f7df525bd4e5b192d91e52f1c75832b1fefb27fb4f4a09b1412b0f4f23d0" + url: "https://pub.dev" + source: hosted + version: "1.0.3" leak_tracker: dependency: transitive description: @@ -592,6 +656,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + nostr_tools: + dependency: "direct main" + description: + name: nostr_tools + sha256: a4c9a4ed938a63bfac8d4e5207b1f1958a0d2775bc41fe90cf6d963ac1dcda90 + url: "https://pub.dev" + source: hosted + version: "1.0.9" package_config: dependency: transitive description: @@ -672,6 +744,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + url: "https://pub.dev" + source: hosted + version: "3.9.1" pool: dependency: transitive description: @@ -942,7 +1022,7 @@ packages: source: hosted version: "1.1.1" web_socket_channel: - dependency: "direct main" + dependency: transitive description: name: web_socket_channel sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b diff --git a/pubspec.yaml b/pubspec.yaml index 16a7690..48eecb8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,8 +14,7 @@ dependencies: path: ^1.8.3 http: ^1.2.0 dio: ^5.4.0 - crypto: ^3.0.3 - web_socket_channel: ^2.4.0 + nostr_tools: ^1.0.9 flutter_dotenv: ^5.1.0 # Firebase dependencies (optional - can be disabled if not needed) firebase_core: ^3.0.0 diff --git a/test/data/immich/immich_service_test.dart b/test/data/immich/immich_service_test.dart index 3d69115..5dc998b 100644 --- a/test/data/immich/immich_service_test.dart +++ b/test/data/immich/immich_service_test.dart @@ -207,11 +207,28 @@ void main() { ]; final mockDio = _createMockDio( - onGet: (path) => Response( - statusCode: 200, - data: mockAssets, - requestOptions: RequestOptions(path: path), - ), + onPost: (path, data) { + if (path == '/api/search/metadata') { + // Return the correct nested response structure + return Response( + statusCode: 200, + data: { + 'assets': { + 'items': mockAssets, + 'total': mockAssets.length, + 'count': mockAssets.length, + 'nextPage': null, + } + }, + requestOptions: RequestOptions(path: path), + ); + } + return Response( + statusCode: 200, + data: {}, + requestOptions: RequestOptions(path: path), + ); + }, ); final immichService = ImmichService( @@ -238,28 +255,38 @@ void main() { test('fetchAssets - pagination', () async { // Arrange final mockDio = _createMockDio( - onGet: (path) { - // Extract query parameters from request options - // The path might be just the endpoint, so we need to check request options - final uri = path.startsWith('http') - ? Uri.parse(path) - : Uri.parse('https://immich.example.com$path'); - final queryParams = uri.queryParameters; - final skip = int.parse(queryParams['skip'] ?? '0'); - final limit = int.parse(queryParams['limit'] ?? '100'); - + onPost: (path, data) { + if (path == '/api/search/metadata') { + // Extract limit and skip from request body (data), not query params + final requestData = data as Map; + final limit = requestData['limit'] as int? ?? 100; + final skip = requestData['skip'] as int? ?? 0; + + return Response( + statusCode: 200, + data: { + 'assets': { + 'items': List.generate( + limit, + (i) => { + 'id': 'asset-${skip + i}', + 'originalFileName': 'image${skip + i}.jpg', + 'createdAt': DateTime.now().toIso8601String(), + 'fileSizeByte': 1000, + 'mimeType': 'image/jpeg', + }, + ), + 'total': 100, // Mock total + 'count': limit, + 'nextPage': null, + } + }, + requestOptions: RequestOptions(path: path), + ); + } return Response( statusCode: 200, - data: List.generate( - limit, - (i) => { - 'id': 'asset-${skip + i}', - 'originalFileName': 'image${skip + i}.jpg', - 'createdAt': DateTime.now().toIso8601String(), - 'fileSizeByte': 1000, - 'mimeType': 'image/jpeg', - }, - ), + data: {}, requestOptions: RequestOptions(path: path), ); }, @@ -287,11 +314,20 @@ void main() { test('fetchAssets - server error', () async { // Arrange final mockDio = _createMockDio( - onGet: (path) => Response( - statusCode: 500, - data: {'error': 'Internal server error'}, - requestOptions: RequestOptions(path: path), - ), + onPost: (path, data) { + if (path == '/api/search/metadata') { + return Response( + statusCode: 500, + data: {'error': 'Internal server error', 'message': 'Internal server error'}, + requestOptions: RequestOptions(path: path), + ); + } + return Response( + statusCode: 200, + data: {}, + requestOptions: RequestOptions(path: path), + ); + }, ); final immichService = ImmichService( diff --git a/test/ui/navigation/main_navigation_scaffold_test.dart b/test/ui/navigation/main_navigation_scaffold_test.dart index 10c13d4..9aac26a 100644 --- a/test/ui/navigation/main_navigation_scaffold_test.dart +++ b/test/ui/navigation/main_navigation_scaffold_test.dart @@ -5,6 +5,7 @@ import 'package:mockito/mockito.dart'; import 'package:app_boilerplate/ui/navigation/main_navigation_scaffold.dart'; import 'package:app_boilerplate/data/local/local_storage_service.dart'; import 'package:app_boilerplate/data/nostr/nostr_service.dart'; +import 'package:app_boilerplate/data/nostr/models/nostr_keypair.dart'; import 'package:app_boilerplate/data/sync/sync_engine.dart'; import 'package:app_boilerplate/data/session/session_service.dart'; import 'package:app_boilerplate/data/firebase/firebase_service.dart'; @@ -36,6 +37,11 @@ void main() { when(mockSessionService.isLoggedIn).thenReturn(false); when(mockSessionService.currentUser).thenReturn(null); when(mockFirebaseService.isEnabled).thenReturn(false); + when(mockNostrService.getRelays()).thenReturn([]); + + // Stub NostrService methods that might be called by UI + final mockKeyPair = NostrKeyPair.generate(); + when(mockNostrService.generateKeyPair()).thenReturn(mockKeyPair); }); Widget createTestWidget() { @@ -68,7 +74,8 @@ void main() { await tester.pumpWidget(createTestWidget()); await tester.pumpAndSettle(); - expect(find.text('Home'), findsOneWidget); // AppBar title + // Home text might appear in AppBar and body, so check for at least one + expect(find.text('Home'), findsWidgets); expect(find.byIcon(Icons.home), findsWidgets); }); @@ -76,8 +83,8 @@ void main() { await tester.pumpWidget(createTestWidget()); await tester.pumpAndSettle(); - // Verify Home is shown initially - expect(find.text('Home'), findsOneWidget); + // Verify Home is shown initially (might appear multiple times) + expect(find.text('Home'), findsWidgets); // Verify navigation structure allows switching expect(find.byType(BottomNavigationBar), findsOneWidget); @@ -116,7 +123,7 @@ void main() { await tester.pumpWidget(createTestWidget()); await tester.pumpAndSettle(); - expect(find.text('Home'), findsOneWidget); // AppBar title + expect(find.text('Home'), findsWidgets); // AppBar title and/or body expect(find.byType(Scaffold), findsWidgets); }); @@ -128,7 +135,7 @@ void main() { expect(find.byType(BottomNavigationBar), findsOneWidget); expect(find.byType(Scaffold), findsWidgets); // IndexedStack is internal - verify it indirectly by checking scaffold renders - expect(find.text('Home'), findsOneWidget); // Home screen should be visible + expect(find.text('Home'), findsWidgets); // Home screen should be visible }); }); } diff --git a/test/ui/navigation/main_navigation_scaffold_test.mocks.dart b/test/ui/navigation/main_navigation_scaffold_test.mocks.dart index f7d50fc..cec17e1 100644 --- a/test/ui/navigation/main_navigation_scaffold_test.mocks.dart +++ b/test/ui/navigation/main_navigation_scaffold_test.mocks.dart @@ -6,23 +6,24 @@ import 'dart:async' as _i9; import 'dart:io' as _i2; -import 'package:app_boilerplate/data/firebase/firebase_service.dart' as _i18; +import 'package:app_boilerplate/data/firebase/firebase_service.dart' as _i19; import 'package:app_boilerplate/data/firebase/models/firebase_config.dart' as _i6; import 'package:app_boilerplate/data/local/local_storage_service.dart' as _i7; import 'package:app_boilerplate/data/local/models/item.dart' as _i10; import 'package:app_boilerplate/data/nostr/models/nostr_event.dart' as _i4; import 'package:app_boilerplate/data/nostr/models/nostr_keypair.dart' as _i3; +import 'package:app_boilerplate/data/nostr/models/nostr_profile.dart' as _i13; import 'package:app_boilerplate/data/nostr/models/nostr_relay.dart' as _i12; import 'package:app_boilerplate/data/nostr/nostr_service.dart' as _i11; import 'package:app_boilerplate/data/session/models/user.dart' as _i5; -import 'package:app_boilerplate/data/session/session_service.dart' as _i17; -import 'package:app_boilerplate/data/sync/models/sync_operation.dart' as _i14; -import 'package:app_boilerplate/data/sync/models/sync_status.dart' as _i15; -import 'package:app_boilerplate/data/sync/sync_engine.dart' as _i13; +import 'package:app_boilerplate/data/session/session_service.dart' as _i18; +import 'package:app_boilerplate/data/sync/models/sync_operation.dart' as _i15; +import 'package:app_boilerplate/data/sync/models/sync_status.dart' as _i16; +import 'package:app_boilerplate/data/sync/sync_engine.dart' as _i14; import 'package:firebase_auth/firebase_auth.dart' as _i8; import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i16; +import 'package:mockito/src/dummies.dart' as _i17; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -377,6 +378,20 @@ class MockNostrService extends _i1.Mock implements _i11.NostrService { )), ) as _i9.Future<_i4.NostrEvent>); + @override + _i9.Future<_i13.NostrProfile?> fetchProfile( + String? publicKey, { + Duration? timeout = const Duration(seconds: 10), + }) => + (super.noSuchMethod( + Invocation.method( + #fetchProfile, + [publicKey], + {#timeout: timeout}, + ), + returnValue: _i9.Future<_i13.NostrProfile?>.value(), + ) as _i9.Future<_i13.NostrProfile?>); + @override void dispose() => super.noSuchMethod( Invocation.method( @@ -390,7 +405,7 @@ class MockNostrService extends _i1.Mock implements _i11.NostrService { /// A class which mocks [SyncEngine]. /// /// See the documentation for Mockito's code generation for more information. -class MockSyncEngine extends _i1.Mock implements _i13.SyncEngine { +class MockSyncEngine extends _i1.Mock implements _i14.SyncEngine { MockSyncEngine() { _i1.throwOnMissingStub(this); } @@ -402,10 +417,10 @@ class MockSyncEngine extends _i1.Mock implements _i13.SyncEngine { ) as int); @override - _i9.Stream<_i14.SyncOperation> get statusStream => (super.noSuchMethod( + _i9.Stream<_i15.SyncOperation> get statusStream => (super.noSuchMethod( Invocation.getter(#statusStream), - returnValue: _i9.Stream<_i14.SyncOperation>.empty(), - ) as _i9.Stream<_i14.SyncOperation>); + returnValue: _i9.Stream<_i15.SyncOperation>.empty(), + ) as _i9.Stream<_i15.SyncOperation>); @override void setNostrKeyPair(_i3.NostrKeyPair? keypair) => super.noSuchMethod( @@ -417,7 +432,7 @@ class MockSyncEngine extends _i1.Mock implements _i13.SyncEngine { ); @override - void setConflictResolution(_i15.ConflictResolution? strategy) => + void setConflictResolution(_i16.ConflictResolution? strategy) => super.noSuchMethod( Invocation.method( #setConflictResolution, @@ -427,25 +442,25 @@ class MockSyncEngine extends _i1.Mock implements _i13.SyncEngine { ); @override - List<_i14.SyncOperation> getPendingOperations() => (super.noSuchMethod( + List<_i15.SyncOperation> getPendingOperations() => (super.noSuchMethod( Invocation.method( #getPendingOperations, [], ), - returnValue: <_i14.SyncOperation>[], - ) as List<_i14.SyncOperation>); + returnValue: <_i15.SyncOperation>[], + ) as List<_i15.SyncOperation>); @override - List<_i14.SyncOperation> getAllOperations() => (super.noSuchMethod( + List<_i15.SyncOperation> getAllOperations() => (super.noSuchMethod( Invocation.method( #getAllOperations, [], ), - returnValue: <_i14.SyncOperation>[], - ) as List<_i14.SyncOperation>); + returnValue: <_i15.SyncOperation>[], + ) as List<_i15.SyncOperation>); @override - void queueOperation(_i14.SyncOperation? operation) => super.noSuchMethod( + void queueOperation(_i15.SyncOperation? operation) => super.noSuchMethod( Invocation.method( #queueOperation, [operation], @@ -456,7 +471,7 @@ class MockSyncEngine extends _i1.Mock implements _i13.SyncEngine { @override _i9.Future syncToImmich( String? itemId, { - _i15.SyncPriority? priority = _i15.SyncPriority.normal, + _i16.SyncPriority? priority = _i16.SyncPriority.normal, }) => (super.noSuchMethod( Invocation.method( @@ -464,7 +479,7 @@ class MockSyncEngine extends _i1.Mock implements _i13.SyncEngine { [itemId], {#priority: priority}, ), - returnValue: _i9.Future.value(_i16.dummyValue( + returnValue: _i9.Future.value(_i17.dummyValue( this, Invocation.method( #syncToImmich, @@ -477,7 +492,7 @@ class MockSyncEngine extends _i1.Mock implements _i13.SyncEngine { @override _i9.Future syncFromImmich( String? assetId, { - _i15.SyncPriority? priority = _i15.SyncPriority.normal, + _i16.SyncPriority? priority = _i16.SyncPriority.normal, }) => (super.noSuchMethod( Invocation.method( @@ -485,7 +500,7 @@ class MockSyncEngine extends _i1.Mock implements _i13.SyncEngine { [assetId], {#priority: priority}, ), - returnValue: _i9.Future.value(_i16.dummyValue( + returnValue: _i9.Future.value(_i17.dummyValue( this, Invocation.method( #syncFromImmich, @@ -498,7 +513,7 @@ class MockSyncEngine extends _i1.Mock implements _i13.SyncEngine { @override _i9.Future syncToNostr( String? itemId, { - _i15.SyncPriority? priority = _i15.SyncPriority.normal, + _i16.SyncPriority? priority = _i16.SyncPriority.normal, }) => (super.noSuchMethod( Invocation.method( @@ -506,7 +521,7 @@ class MockSyncEngine extends _i1.Mock implements _i13.SyncEngine { [itemId], {#priority: priority}, ), - returnValue: _i9.Future.value(_i16.dummyValue( + returnValue: _i9.Future.value(_i17.dummyValue( this, Invocation.method( #syncToNostr, @@ -518,7 +533,7 @@ class MockSyncEngine extends _i1.Mock implements _i13.SyncEngine { @override _i9.Future> syncAll( - {_i15.SyncPriority? priority = _i15.SyncPriority.normal}) => + {_i16.SyncPriority? priority = _i16.SyncPriority.normal}) => (super.noSuchMethod( Invocation.method( #syncAll, @@ -575,7 +590,7 @@ class MockSyncEngine extends _i1.Mock implements _i13.SyncEngine { /// A class which mocks [SessionService]. /// /// See the documentation for Mockito's code generation for more information. -class MockSessionService extends _i1.Mock implements _i17.SessionService { +class MockSessionService extends _i1.Mock implements _i18.SessionService { MockSessionService() { _i1.throwOnMissingStub(this); } @@ -616,6 +631,22 @@ class MockSessionService extends _i1.Mock implements _i17.SessionService { )), ) as _i9.Future<_i5.User>); + @override + _i9.Future<_i5.User> loginWithNostr(String? nsecOrNpub) => + (super.noSuchMethod( + Invocation.method( + #loginWithNostr, + [nsecOrNpub], + ), + returnValue: _i9.Future<_i5.User>.value(_FakeUser_3( + this, + Invocation.method( + #loginWithNostr, + [nsecOrNpub], + ), + )), + ) as _i9.Future<_i5.User>); + @override _i9.Future logout({bool? clearCache = true}) => (super.noSuchMethod( Invocation.method( @@ -664,7 +695,7 @@ class MockSessionService extends _i1.Mock implements _i17.SessionService { /// A class which mocks [FirebaseService]. /// /// See the documentation for Mockito's code generation for more information. -class MockFirebaseService extends _i1.Mock implements _i18.FirebaseService { +class MockFirebaseService extends _i1.Mock implements _i19.FirebaseService { MockFirebaseService() { _i1.throwOnMissingStub(this); } @@ -780,7 +811,7 @@ class MockFirebaseService extends _i1.Mock implements _i18.FirebaseService { path, ], ), - returnValue: _i9.Future.value(_i16.dummyValue( + returnValue: _i9.Future.value(_i17.dummyValue( this, Invocation.method( #uploadFile,