diff --git a/packages/ndk/lib/data_layer/models/wallet_transaction_model.dart b/packages/ndk/lib/data_layer/models/wallet_transaction_model.dart new file mode 100644 index 000000000..a38cfd8d1 --- /dev/null +++ b/packages/ndk/lib/data_layer/models/wallet_transaction_model.dart @@ -0,0 +1,272 @@ +import '../../domain_layer/entities/cashu/cashu_keyset.dart'; +import '../../domain_layer/entities/cashu/cashu_quote.dart'; +import '../../domain_layer/entities/cashu/cashu_quote_melt.dart'; +import '../../domain_layer/entities/wallet/wallet_transaction.dart'; +import '../../domain_layer/entities/wallet/wallet_type.dart'; + +/// A helper model for wallet transaction JSON serialization and deserialization. +/// +class WalletTransactionModel { + WalletTransactionModel._(); + + /// Deserializes a wallet transaction from JSON. + static WalletTransaction fromJson(Map json) { + final walletType = WalletType.fromValue(json['walletType'] as String); + + switch (walletType) { + case WalletType.CASHU: + return CashuWalletTransactionModel.fromJson(json); + case WalletType.NWC: + return NwcWalletTransactionModel.fromJson(json); + case WalletType.LNURL: + return LnurlWalletTransactionModel.fromJson(json); + } + } + + /// Converts a domain wallet transaction to a serializable JSON map. + static Map toJson(WalletTransaction transaction) { + if (transaction is CashuWalletTransaction) { + return CashuWalletTransactionModel.fromEntity(transaction).toJson(); + } + if (transaction is NwcWalletTransaction) { + return NwcWalletTransactionModel.fromEntity(transaction).toJson(); + } + if (transaction is LnurlWalletTransaction) { + return LnurlWalletTransactionModel.fromEntity(transaction).toJson(); + } + + return _baseJson(transaction); + } + + /// Converts a domain transaction into the corresponding model variant. + static WalletTransaction fromEntity(WalletTransaction transaction) { + if (transaction is CashuWalletTransaction) { + return CashuWalletTransactionModel.fromEntity(transaction); + } + if (transaction is NwcWalletTransaction) { + return NwcWalletTransactionModel.fromEntity(transaction); + } + if (transaction is LnurlWalletTransaction) { + return LnurlWalletTransactionModel.fromEntity(transaction); + } + return transaction; + } + + static Map _baseJson(WalletTransaction transaction) { + return { + 'id': transaction.id, + 'walletId': transaction.walletId, + 'changeAmount': transaction.changeAmount, + 'unit': transaction.unit, + 'walletType': transaction.walletType.toString(), + 'state': transaction.state.value, + 'completionMsg': transaction.completionMsg, + 'transactionDate': transaction.transactionDate, + 'initiatedDate': transaction.initiatedDate, + 'metadata': transaction.metadata, + }; + } +} + +class CashuWalletTransactionModel extends CashuWalletTransaction { + CashuWalletTransactionModel({ + required super.id, + required super.walletId, + required super.changeAmount, + required super.unit, + required super.walletType, + required super.state, + required super.mintUrl, + super.completionMsg, + super.transactionDate, + super.initiatedDate, + super.note, + super.method, + super.qoute, + super.qouteMelt, + super.usedKeysets, + super.token, + super.proofPubKeys, + super.metadata, + }); + + factory CashuWalletTransactionModel.fromEntity( + CashuWalletTransaction transaction, + ) { + return CashuWalletTransactionModel( + id: transaction.id, + walletId: transaction.walletId, + changeAmount: transaction.changeAmount, + unit: transaction.unit, + walletType: transaction.walletType, + state: transaction.state, + mintUrl: transaction.mintUrl, + note: transaction.note, + method: transaction.method, + qoute: transaction.qoute, + qouteMelt: transaction.qouteMelt, + usedKeysets: transaction.usedKeysets, + token: transaction.token, + proofPubKeys: transaction.proofPubKeys, + completionMsg: transaction.completionMsg, + transactionDate: transaction.transactionDate, + initiatedDate: transaction.initiatedDate, + metadata: transaction.metadata, + ); + } + + factory CashuWalletTransactionModel.fromJson(Map json) { + final metadata = Map.from(json['metadata'] as Map? ?? {}); + + final rawQuote = json['qoute'] as Map? ?? + metadata['qoute'] as Map?; + final rawQuoteMelt = json['qouteMelt'] as Map? ?? + metadata['qouteMelt'] as Map?; + + final rawUsedKeysets = json['usedKeysets'] as List? ?? + metadata['usedKeyset'] as List? ?? + metadata['usedKeysets'] as List?; + + final rawProofPubKeys = json['proofPubKeys'] as List? ?? + metadata['proofPubKeys'] as List?; + + return CashuWalletTransactionModel( + id: json['id'] as String, + walletId: json['walletId'] as String, + changeAmount: json['changeAmount'] as int, + unit: json['unit'] as String, + walletType: WalletType.fromValue(json['walletType'] as String), + state: WalletTransactionState.fromValue(json['state'] as String), + completionMsg: json['completionMsg'] as String?, + transactionDate: json['transactionDate'] as int?, + initiatedDate: json['initiatedDate'] as int?, + mintUrl: json['mintUrl'] as String? ?? metadata['mintUrl'] as String, + note: json['note'] as String? ?? metadata['note'] as String?, + method: json['method'] as String? ?? metadata['method'] as String?, + qoute: rawQuote != null ? CashuQuote.fromJson(rawQuote) : null, + qouteMelt: + rawQuoteMelt != null ? CashuQuoteMelt.fromJson(rawQuoteMelt) : null, + usedKeysets: rawUsedKeysets + ?.map((entry) => CahsuKeyset.fromJson(entry as Map)) + .toList(), + token: json['token'] as String? ?? metadata['token'] as String?, + proofPubKeys: rawProofPubKeys?.map((entry) => entry.toString()).toList(), + metadata: metadata, + ); + } + + Map toJson() { + return { + ...WalletTransactionModel._baseJson(this), + 'metadata': metadata, + }; + } +} + +class NwcWalletTransactionModel extends NwcWalletTransaction { + NwcWalletTransactionModel({ + required super.id, + required super.walletId, + required super.changeAmount, + required super.unit, + required super.walletType, + required super.state, + required super.metadata, + super.completionMsg, + super.transactionDate, + super.initiatedDate, + }); + + factory NwcWalletTransactionModel.fromEntity( + NwcWalletTransaction transaction, + ) { + return NwcWalletTransactionModel( + id: transaction.id, + walletId: transaction.walletId, + changeAmount: transaction.changeAmount, + unit: transaction.unit, + walletType: transaction.walletType, + state: transaction.state, + metadata: transaction.metadata, + completionMsg: transaction.completionMsg, + transactionDate: transaction.transactionDate, + initiatedDate: transaction.initiatedDate, + ); + } + + factory NwcWalletTransactionModel.fromJson(Map json) { + return NwcWalletTransactionModel( + id: json['id'] as String, + walletId: json['walletId'] as String, + changeAmount: json['changeAmount'] as int, + unit: json['unit'] as String, + walletType: WalletType.fromValue(json['walletType'] as String), + state: WalletTransactionState.fromValue(json['state'] as String), + completionMsg: json['completionMsg'] as String?, + transactionDate: json['transactionDate'] as int?, + initiatedDate: json['initiatedDate'] as int?, + metadata: Map.from(json['metadata'] as Map? ?? {}), + ); + } + + Map toJson() { + return { + ...WalletTransactionModel._baseJson(this), + 'metadata': metadata, + }; + } +} + +class LnurlWalletTransactionModel extends LnurlWalletTransaction { + LnurlWalletTransactionModel({ + required super.id, + required super.walletId, + required super.changeAmount, + required super.unit, + required super.walletType, + required super.state, + required super.metadata, + super.completionMsg, + super.transactionDate, + super.initiatedDate, + }); + + factory LnurlWalletTransactionModel.fromEntity( + LnurlWalletTransaction transaction, + ) { + return LnurlWalletTransactionModel( + id: transaction.id, + walletId: transaction.walletId, + changeAmount: transaction.changeAmount, + unit: transaction.unit, + walletType: transaction.walletType, + state: transaction.state, + metadata: transaction.metadata, + completionMsg: transaction.completionMsg, + transactionDate: transaction.transactionDate, + initiatedDate: transaction.initiatedDate, + ); + } + + factory LnurlWalletTransactionModel.fromJson(Map json) { + return LnurlWalletTransactionModel( + id: json['id'] as String, + walletId: json['walletId'] as String, + changeAmount: json['changeAmount'] as int, + unit: json['unit'] as String, + walletType: WalletType.fromValue(json['walletType'] as String), + state: WalletTransactionState.fromValue(json['state'] as String), + completionMsg: json['completionMsg'] as String?, + transactionDate: json['transactionDate'] as int?, + initiatedDate: json['initiatedDate'] as int?, + metadata: Map.from(json['metadata'] as Map? ?? {}), + ); + } + + Map toJson() { + return { + ...WalletTransactionModel._baseJson(this), + 'metadata': metadata, + }; + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/cashu/cashu.dart b/packages/ndk/lib/domain_layer/usecases/cashu/cashu.dart index d73ae3aee..4402369ef 100644 --- a/packages/ndk/lib/domain_layer/usecases/cashu/cashu.dart +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu.dart @@ -19,6 +19,7 @@ import '../../repositories/cache_manager.dart'; import '../../repositories/cashu_key_derivation.dart'; import '../../repositories/cashu_repo.dart'; import '../../repositories/wallets_repo.dart'; +import 'cashu_export_import.dart'; import 'cashu_bdhke.dart'; import 'cashu_cache_decorator.dart'; import 'cashu_keysets.dart'; @@ -39,6 +40,8 @@ class Cashu { late final CashuSeed _cashuSeed; + late final CashuStateExportImport _cashuExportImport; + final CashuKeyDerivation _cashuKeyDerivation; Cashu({ @@ -64,6 +67,11 @@ class Cashu { _cashuSeed = CashuSeed( userSeedPhrase: cashuUserSeedphrase, ); + _cashuExportImport = CashuStateExportImport( + cacheManagerCashu: _cacheManagerCashu, + walletsRepo: _walletsRepo, + cashuSeed: _cashuSeed, + ); if (cashuUserSeedphrase == null) { Logger.log.w(() => 'Cashu initialized without user seed phrase, cashu features will not work \nSet the seed phrase using NdkConfig or Cashu.setCashuSeedPhrase()'); @@ -100,6 +108,74 @@ class Cashu { return _cashuSeed; } + /// Export a full backup of the local cashu database (proofs, keysets, mint + /// infos, derivation counters and transactions) as a JSON map. + /// + /// The global seed phrase is NOT included by default — it is wallet + /// independent and should be backed up separately. Set [includeSeedPhrase] + /// true only for a single self-contained backup, and then treat the result + /// like a private key. See [CashuStateExportImport]. + Future> exportCashuState({ + bool includeSeedPhrase = false, + bool includeTransactions = true, + }) { + return _cashuExportImport.exportToMap( + includeSeedPhrase: includeSeedPhrase, + includeTransactions: includeTransactions, + ); + } + + /// Export a full backup of all local cashu state as a JSON string. + /// See [exportCashuState]. + Future exportCashuStateJsonString({ + bool includeSeedPhrase = false, + bool includeTransactions = true, + bool pretty = true, + }) { + return _cashuExportImport.exportToJsonString( + includeSeedPhrase: includeSeedPhrase, + includeTransactions: includeTransactions, + pretty: pretty, + ); + } + + /// Restore cashu state from a backup map produced by [exportCashuState]. + /// + /// NOTE: the restored seed phrase is only loaded into memory; persist + /// [CashuStateImportResult.seedPhrase] to secure storage to finish the + /// restore. After restoring you may want to call [restore] to re-sync proofs + /// from each mint. See [CashuStateExportImport]. + Future importCashuState( + Map json, { + bool restoreSeedPhrase = true, + bool restoreTransactions = true, + }) async { + final result = await _cashuExportImport.importFromMap( + json, + restoreSeedPhrase: restoreSeedPhrase, + restoreTransactions: restoreTransactions, + ); + await _updateBalances(); + await _updateTransactions(); + return result; + } + + /// Restore cashu state from a backup JSON string. See [importCashuState]. + Future importCashuStateJsonString( + String jsonString, { + bool restoreSeedPhrase = true, + bool restoreTransactions = true, + }) async { + final result = await _cashuExportImport.importFromJsonString( + jsonString, + restoreSeedPhrase: restoreSeedPhrase, + restoreTransactions: restoreTransactions, + ); + await _updateBalances(); + await _updateTransactions(); + return result; + } + /// Restores proofs from a mint using the wallet's seed phrase. /// /// This implements NUT-09 (Restore) using NUT-13 (Deterministic Secrets). @@ -272,6 +348,20 @@ class Cashu { _balanceSubject!.add(balances); } + Future _updateTransactions() async { + final latestTransactions = await _getLatestTransactionsDb(); + _latestTransactions + ..clear() + ..addAll(latestTransactions); + _latestTransactionsSubject?.add(_latestTransactions); + + final pendingTransactions = await _getPendingTransactionsDb(); + _pendingTransactions + ..clear() + ..addAll(pendingTransactions); + _pendingTransactionsSubject?.add(_pendingTransactions.toList()); + } + /// list of balances for all mints BehaviorSubject> get balances { if (_balanceSubject == null) { diff --git a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_cache_decorator.dart b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_cache_decorator.dart index f397623d0..56b95efe8 100644 --- a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_cache_decorator.dart +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_cache_decorator.dart @@ -2,6 +2,7 @@ import 'dart:async'; import '../../../shared/helpers/mutex_simple.dart'; import '../../entities/cashu/cashu_keyset.dart'; +import '../../entities/cashu/cashu_mint_info.dart'; import '../../entities/cashu/cashu_proof.dart'; import '../../repositories/cache_manager.dart'; @@ -15,6 +16,15 @@ class CashuCacheDecorator implements CacheManager { }) : _delegate = cacheManager, _mutex = mutex ?? MutexSimple(); + @override + Future?> getMintInfos({ + List? mintUrls, + }) async { + return await _mutex.synchronized(() async { + return await _delegate.getMintInfos(mintUrls: mintUrls); + }); + } + @override Future saveProofs({ required List proofs, @@ -66,6 +76,52 @@ class CashuCacheDecorator implements CacheManager { }); } + @override + Future saveMintInfo({ + required CashuMintInfo mintInfo, + }) async { + await _mutex.synchronized(() async { + await _delegate.saveMintInfo(mintInfo: mintInfo); + }); + } + + @override + Future removeMintInfo({ + required String mintUrl, + }) async { + await _mutex.synchronized(() async { + await _delegate.removeMintInfo(mintUrl: mintUrl); + }); + } + + @override + Future getCashuSecretCounter({ + required String mintUrl, + required String keysetId, + }) async { + return await _mutex.synchronized(() async { + return await _delegate.getCashuSecretCounter( + mintUrl: mintUrl, + keysetId: keysetId, + ); + }); + } + + @override + Future setCashuSecretCounter({ + required String mintUrl, + required String keysetId, + required int counter, + }) async { + await _mutex.synchronized(() async { + await _delegate.setCashuSecretCounter( + mintUrl: mintUrl, + keysetId: keysetId, + counter: counter, + ); + }); + } + @override dynamic noSuchMethod(Invocation invocation) { throw UnimplementedError( diff --git a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_export_import.dart b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_export_import.dart new file mode 100644 index 000000000..b8c7ca7a7 --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_export_import.dart @@ -0,0 +1,312 @@ +import 'dart:convert'; + +import '../../../shared/logger/logger.dart'; +import '../../../data_layer/models/wallet_transaction_model.dart'; +import '../../entities/cashu/cashu_keyset.dart'; +import '../../entities/cashu/cashu_mint_info.dart'; +import '../../entities/cashu/cashu_proof.dart'; +import '../../entities/wallet/wallet_transaction.dart'; +import '../../entities/wallet/wallet_type.dart'; +import '../../repositories/wallets_repo.dart'; +import 'cashu_cache_decorator.dart'; +import 'cashu_seed.dart'; + +/// Full export / import of all local cashu state. +/// +/// IMPORTANT: Used as a last resort! To setup a new device use [restore()] using the seed phrase and mint urls. +/// +/// An export is a plain JSON document containing everything required to import +/// a wallet on a fresh device: +/// - the BIP39 seed phrase (controls all funds, derives deterministic secrets) +/// - unspent (and optionally spent/pending) proofs (the actual ecash) +/// - NUT-13 derivation counters (so newly minted secrets don't collide) +/// - cached keysets and mint infos (re-fetchable, kept for offline restore) +/// - transaction history (not recoverable from the network) +/// +/// SECURITY: an export grants full control over the funds. +/// Treat the exported document like a private key. Set +/// [includeSeedPhrase] to include the seed phrase in the export, and [restoreSeedPhrase] to load it on import. +class CashuStateExportImport { + /// bump when the on-disk format changes incompatibly + static const int exportVersion = 1; + static const String exportType = 'ndk-cashu-export'; + + final CashuCacheDecorator _cacheManagerCashu; + + final WalletsRepo _walletsRepo; + final CashuSeed _cashuSeed; + + CashuStateExportImport({ + required CashuCacheDecorator cacheManagerCashu, + required WalletsRepo walletsRepo, + required CashuSeed cashuSeed, + }) : _cacheManagerCashu = cacheManagerCashu, + _walletsRepo = walletsRepo, + _cashuSeed = cashuSeed; + + /// Export all cashu state as a JSON-serializable map. + /// + /// [includeSeedPhrase] - include the BIP39 seed phrase (default false). The + /// seed is a global, wallet-independent secret managed separately (e.g. via + /// [CashuSeed] / secure storage); it is normally backed up on its own and not + /// bundled with the per-mint cashu database. Enable only if you explicitly + /// want a single self-contained export, and treat the result like a private + /// key. + /// [includeTransactions] - include the cashu transaction history (default + /// true). History is informational and not required to recover funds. + Future> exportToMap({ + bool includeSeedPhrase = false, + bool includeTransactions = true, + }) async { + final mintInfos = + await _cacheManagerCashu.getMintInfos() ?? []; + final keysets = await _cacheManagerCashu.getKeysets(); + + // mints we have any local state for: derived from keysets and mint infos + final mintUrls = { + ...keysets.map((k) => k.mintUrl), + ...mintInfos.expand((m) => m.urls), + }; + + // proofs are stored per mint and queried per state; collect all states so + // pending / spent history survives the round-trip too + final proofsJson = >[]; + for (final mintUrl in mintUrls) { + for (final state in CashuProofState.values) { + final proofs = await _cacheManagerCashu.getProofs( + mintUrl: mintUrl, + state: state, + ); + for (final proof in proofs) { + proofsJson.add(proof.toJson() + ..['mintUrl'] = mintUrl + ..['state'] = state.value); + } + } + } + + // NUT-13 derivation counters, one per keyset + final countersJson = >[]; + for (final keyset in keysets) { + final counter = await _cacheManagerCashu.getCashuSecretCounter( + mintUrl: keyset.mintUrl, + keysetId: keyset.id, + ); + countersJson.add({ + 'mintUrl': keyset.mintUrl, + 'keysetId': keyset.id, + 'counter': counter, + }); + } + + final export = { + 'type': exportType, + 'version': exportVersion, + 'createdAt': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'mintInfos': mintInfos.map((m) => m.toJson()).toList(), + 'keysets': keysets.map((k) => k.toJson()).toList(), + 'proofs': proofsJson, + 'counters': countersJson, + }; + + if (includeSeedPhrase) { + try { + export['seedPhrase'] = _cashuSeed.getSeedPhrase().sentence; + } catch (_) { + Logger.log.w(() => + 'Cashu export: no seed phrase set, exporting without it. The export will not be restorable on a new device.'); + } + } + + if (includeTransactions) { + final transactions = await _walletsRepo.getTransactions( + walletType: WalletType.CASHU, + ); + export['transactions'] = + transactions.map(WalletTransactionModel.toJson).toList(); + } + + return export; + } + + /// Export all cashu state as a JSON string. See [exportToMap]. + Future exportToJsonString({ + bool includeSeedPhrase = false, + bool includeTransactions = true, + bool pretty = true, + }) async { + final map = await exportToMap( + includeSeedPhrase: includeSeedPhrase, + includeTransactions: includeTransactions, + ); + if (pretty) { + return const JsonEncoder.withIndent(' ').convert(map); + } + return jsonEncode(map); + } + + /// Restore cashu state from a export [json] produced by [exportToMap]. + /// + /// Restored data is merged into the existing local state (saves overwrite by + /// key, they do not wipe other mints first). + /// + /// [restoreSeedPhrase] - load the backed-up seed phrase into the running + /// [CashuSeed] (default true). NOTE: this only sets the in-memory seed; the + /// caller is responsible for persisting it to secure storage. The seed phrase + /// is also returned in [CashuStateImportResult] for that purpose. + /// [restoreTransactions] - restore the transaction history (default true). + Future importFromMap( + Map json, { + bool restoreSeedPhrase = true, + bool restoreTransactions = true, + }) async { + // Phase 1: Validate header + final type = json['type']; + if (type != exportType) { + throw ArgumentError( + 'Not a cashu state export: expected type "$exportType", got "$type"'); + } + final version = json['version']; + if (version is! int || version > exportVersion) { + throw ArgumentError( + 'Unsupported cashu state export version: $version (this build supports up to $exportVersion)'); + } + + // Phase 2: Parse all data into memory structures (no mutations yet) + String? restoredSeedPhrase; + final seedPhrase = json['seedPhrase']; + if (seedPhrase is String && seedPhrase.trim().isNotEmpty) { + restoredSeedPhrase = seedPhrase; + } + + // Parse mint infos + final parsedMintInfos = []; + final mintInfosJson = (json['mintInfos'] as List?) ?? const []; + for (final m in mintInfosJson) { + parsedMintInfos.add(CashuMintInfo.fromJson(m as Map)); + } + + // Parse keysets + final parsedKeysets = []; + final keysetsJson = (json['keysets'] as List?) ?? const []; + for (final k in keysetsJson) { + parsedKeysets.add(CahsuKeyset.fromJson(k as Map)); + } + + // Parse proofs + final proofsByMint = >{}; + final proofsJson = (json['proofs'] as List?) ?? const []; + for (final p in proofsJson) { + final map = p as Map; + final mintUrl = map['mintUrl'] as String; + (proofsByMint[mintUrl] ??= []).add( + CashuProof( + keysetId: map['id'] as String, + amount: map['amount'] as int, + secret: map['secret'] as String, + unblindedSig: map['C'] as String, + state: + CashuProofState.fromValue(map['state'] as String? ?? 'UNSPENT'), + ), + ); + } + + // Parse counters + final parsedCounters = >[]; + final countersJson = (json['counters'] as List?) ?? const []; + for (final c in countersJson) { + parsedCounters.add(c as Map); + } + + // Parse transactions + List parsedTransactions = []; + if (restoreTransactions) { + final transactionsJson = (json['transactions'] as List?) ?? const []; + parsedTransactions = transactionsJson + .map( + (t) => WalletTransactionModel.fromJson(t as Map)) + .toList(); + } + + // Phase 3: All parsing succeeded; apply mutations in order + if (restoreSeedPhrase && restoredSeedPhrase != null) { + await _cashuSeed.setSeedPhrase(seedPhrase: restoredSeedPhrase); + } + + for (final mintInfo in parsedMintInfos) { + await _cacheManagerCashu.saveMintInfo(mintInfo: mintInfo); + } + + for (final keyset in parsedKeysets) { + await _cacheManagerCashu.saveKeyset(keyset); + } + + for (final entry in proofsByMint.entries) { + await _cacheManagerCashu.saveProofs( + proofs: entry.value, + mintUrl: entry.key, + ); + } + + for (final counter in parsedCounters) { + await _cacheManagerCashu.setCashuSecretCounter( + mintUrl: counter['mintUrl'] as String, + keysetId: counter['keysetId'] as String, + counter: counter['counter'] as int, + ); + } + + if (parsedTransactions.isNotEmpty) { + await _walletsRepo.saveTransactions(parsedTransactions); + } + + final totalRestoredProofs = + proofsByMint.values.fold(0, (sum, list) => sum + list.length); + Logger.log.i(() => + 'Cashu state export imported: $totalRestoredProofs proofs, ${parsedKeysets.length} keysets, ${parsedMintInfos.length} mint infos, ${parsedTransactions.length} transactions'); + + return CashuStateImportResult( + seedPhrase: restoredSeedPhrase, + restoredProofs: totalRestoredProofs, + restoredKeysets: parsedKeysets.length, + restoredMintInfos: parsedMintInfos.length, + restoredTransactions: parsedTransactions.length, + ); + } + + /// Restore cashu state from a export JSON string. See [importFromMap]. + Future importFromJsonString( + String jsonString, { + bool restoreSeedPhrase = true, + bool restoreTransactions = true, + }) { + final decoded = jsonDecode(jsonString); + if (decoded is! Map) { + throw ArgumentError('Export JSON must be an object'); + } + return importFromMap( + decoded, + restoreSeedPhrase: restoreSeedPhrase, + restoreTransactions: restoreTransactions, + ); + } +} + +/// Summary of what [CashuStateExportImport.importFromMap] restored. +class CashuStateImportResult { + /// the seed phrase contained in the import, if any. The caller should + /// persist this to secure storage to complete the restore. + final String? seedPhrase; + final int restoredProofs; + final int restoredKeysets; + final int restoredMintInfos; + final int restoredTransactions; + + CashuStateImportResult({ + required this.seedPhrase, + required this.restoredProofs, + required this.restoredKeysets, + required this.restoredMintInfos, + required this.restoredTransactions, + }); +} diff --git a/packages/ndk/lib/ndk.dart b/packages/ndk/lib/ndk.dart index df4f7d7cf..50457cf3f 100644 --- a/packages/ndk/lib/ndk.dart +++ b/packages/ndk/lib/ndk.dart @@ -97,6 +97,7 @@ export 'domain_layer/usecases/search/search.dart'; export 'domain_layer/usecases/gift_wrap/gift_wrap.dart'; export 'domain_layer/usecases/cashu/cashu.dart'; export 'domain_layer/usecases/cashu/cashu_seed.dart'; +export 'domain_layer/usecases/cashu/cashu_export_import.dart'; export 'domain_layer/entities/cashu/cashu_blinded_message.dart'; export 'domain_layer/entities/cashu/cashu_blinded_signature.dart'; export 'domain_layer/entities/cashu/cashu_restore_result.dart'; diff --git a/packages/ndk/test/cashu/cashu_import_export_test.dart b/packages/ndk/test/cashu/cashu_import_export_test.dart new file mode 100644 index 000000000..9717c0a91 --- /dev/null +++ b/packages/ndk/test/cashu/cashu_import_export_test.dart @@ -0,0 +1,154 @@ +import 'package:ndk/data_layer/repositories/wallets/mem_wallets_repo.dart'; +import 'package:ndk/domain_layer/entities/cashu/cashu_keyset.dart'; +import 'package:ndk/domain_layer/entities/cashu/cashu_proof.dart'; +import 'package:ndk/domain_layer/usecases/cashu/cashu_cache_decorator.dart'; +import 'package:ndk/ndk.dart'; +import 'package:test/test.dart'; + +CashuStateExportImport _export( + CacheManager cache, MemWalletsRepo wallets, CashuSeed seed) { + return CashuStateExportImport( + cacheManagerCashu: CashuCacheDecorator(cacheManager: cache), + walletsRepo: wallets, + cashuSeed: seed, + ); +} + +CahsuKeyset _keyset(String mintUrl) => CahsuKeyset( + id: 'keyset1', + mintUrl: mintUrl, + unit: 'sat', + active: true, + inputFeePPK: 0, + mintKeyPairs: {CahsuMintKeyPair(amount: 1, pubkey: 'abc')}, + ); + +void main() { + const mintUrl = 'https://mint.test'; + + test('export then import round-trips proofs, keysets and counters', () async { + final srcCache = MemCacheManager(); + final srcWallets = MemWalletsRepo(); + final seed = CashuSeed(); + await seed.setSeedPhrase(seedPhrase: CashuSeed.generateSeedPhrase()); + + await srcCache.saveKeyset(_keyset(mintUrl)); + await srcCache.saveProofs( + proofs: [ + CashuProof( + keysetId: 'keyset1', + amount: 8, + secret: 'secret-a', + unblindedSig: 'sig-a', + ), + CashuProof( + keysetId: 'keyset1', + amount: 2, + secret: 'secret-b', + unblindedSig: 'sig-b', + ), + ], + mintUrl: mintUrl, + ); + await srcCache.setCashuSecretCounter( + mintUrl: mintUrl, + keysetId: 'keyset1', + counter: 42, + ); + + final exported = await _export(srcCache, srcWallets, seed) + .exportToMap(includeSeedPhrase: true); + + expect(exported['type'], equals(CashuStateExportImport.exportType)); + expect(exported['seedPhrase'], equals(seed.getSeedPhrase().sentence)); + expect((exported['proofs'] as List).length, equals(2)); + expect((exported['counters'] as List).single['counter'], equals(42)); + + // restore into a fresh, empty device + final dstCache = MemCacheManager(); + final dstWallets = MemWalletsRepo(); + final dstSeed = CashuSeed(); + + final result = + await _export(dstCache, dstWallets, dstSeed).importFromMap(exported); + + expect(result.restoredProofs, equals(2)); + expect(result.restoredKeysets, equals(1)); + expect(result.seedPhrase, equals(seed.getSeedPhrase().sentence)); + // seed was loaded into the destination seed instance + expect(dstSeed.getSeedPhrase().sentence, + equals(seed.getSeedPhrase().sentence)); + + final restoredProofs = await dstCache.getProofs(mintUrl: mintUrl); + expect(restoredProofs.length, equals(2)); + + final restoredCounter = await dstCache.getCashuSecretCounter( + mintUrl: mintUrl, + keysetId: 'keyset1', + ); + expect(restoredCounter, equals(42)); + + final restoredKeysets = await dstCache.getKeysets(mintUrl: mintUrl); + expect(restoredKeysets.single.id, equals('keyset1')); + }); + + test('json string round-trips', () async { + final cache = MemCacheManager(); + final wallets = MemWalletsRepo(); + final seed = CashuSeed(); + await seed.setSeedPhrase(seedPhrase: CashuSeed.generateSeedPhrase()); + + await cache.saveKeyset(_keyset(mintUrl)); + await cache.saveProofs( + proofs: [ + CashuProof( + keysetId: 'keyset1', + amount: 1, + secret: 'secret-c', + unblindedSig: 'sig-c', + ), + CashuProof( + keysetId: 'keyset1', + amount: 2, + secret: 'secret-c-2', + unblindedSig: 'sig-c-2', + ), + ], + mintUrl: mintUrl, + ); + + final jsonString = await _export(cache, wallets, seed).exportToJsonString(); + + final dstCache = MemCacheManager(); + final result = await _export(dstCache, MemWalletsRepo(), CashuSeed()) + .importFromJsonString(jsonString); + + expect(result.restoredProofs, equals(2)); + expect((await dstCache.getProofs(mintUrl: mintUrl)).length, equals(2)); + expect((await dstCache.getKeysets(mintUrl: mintUrl)).first.id, + equals('keyset1')); + + // check that the secret amount and unblinded sig round-tripped correctly (they are the most sensitive fields to get wrong in the serialization) + final restoredProof = (await dstCache.getProofs(mintUrl: mintUrl)).first; + expect(restoredProof.secret, equals('secret-c')); + expect(restoredProof.unblindedSig, equals('sig-c')); + }); + + test('seed phrase is excluded by default', () async { + final cache = MemCacheManager(); + final seed = CashuSeed(); + await seed.setSeedPhrase(seedPhrase: CashuSeed.generateSeedPhrase()); + + final exported = await _export(cache, MemWalletsRepo(), seed).exportToMap(); + + expect(exported.containsKey('seedPhrase'), isFalse); + }); + + test('rejects non-backup json', () async { + final backup = _export(MemCacheManager(), MemWalletsRepo(), CashuSeed()); + expect( + () => backup.importFromMap({'type': 'something-else', 'version': 1}), + throwsArgumentError, + ); + }); +} diff --git a/packages/ndk/test/cashu/wallet_transaction_model_test.dart b/packages/ndk/test/cashu/wallet_transaction_model_test.dart new file mode 100644 index 000000000..71bee3ff6 --- /dev/null +++ b/packages/ndk/test/cashu/wallet_transaction_model_test.dart @@ -0,0 +1,94 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:test/test.dart'; +import 'package:ndk/data_layer/models/wallet_transaction_model.dart'; +import 'package:ndk/domain_layer/entities/wallet/wallet_transaction.dart'; +import 'package:ndk/domain_layer/entities/wallet/wallet_type.dart'; +import 'package:ndk/domain_layer/entities/cashu/cashu_keyset.dart'; +import 'package:ndk/domain_layer/entities/cashu/cashu_quote.dart'; +import 'package:ndk/domain_layer/entities/cashu/cashu_quote_melt.dart'; +import 'package:ndk/domain_layer/usecases/cashu/cashu_keypair.dart'; + +void main() { + test('cashu transaction json encode/decode round-trip (many iterations)', () { + const mintUrl = 'https://mint.test'; + + final rnd = Random(42); + const iterations = 100; + + for (var i = 0; i < iterations; i++) { + final keyset = CahsuKeyset( + id: 'keyset-$i', + mintUrl: mintUrl, + unit: 'sat', + active: i % 2 == 0, + inputFeePPK: i, + mintKeyPairs: {CahsuMintKeyPair(amount: i + 1, pubkey: 'pk-$i')}, + ); + + final quoteKey = CashuKeypair(privateKey: 'priv-$i', publicKey: 'pub-$i'); + final quote = CashuQuote( + quoteId: 'quote-$i', + request: 'req-$i', + amount: i * 10 + 1, + unit: 'sat', + state: CashuQuoteState.unpaid, + expiry: 3600 + i, + mintUrl: mintUrl, + quoteKey: quoteKey, + ); + + final quoteMelt = CashuQuoteMelt( + quoteId: 'melt-$i', + amount: i + 5, + feeReserve: i % 3, + paid: i % 2 == 0, + expiry: 1000 + i, + mintUrl: mintUrl, + state: CashuQuoteState.unpaid, + unit: 'sat', + request: 'melt-req-$i', + ); + + final proofCount = 1 + rnd.nextInt(3); + final proofPks = List.generate(proofCount, (idx) => 'pk-$i-$idx'); + + final tx = CashuWalletTransaction( + id: 'tx-$i', + walletId: 'wallet-$i', + changeAmount: i * (i.isEven ? 1 : -1), + unit: 'sat', + walletType: WalletType.CASHU, + state: WalletTransactionState + .values[i % WalletTransactionState.values.length], + mintUrl: mintUrl, + note: 'note-$i', + method: 'method-${i % 2}', + qoute: quote, + qouteMelt: quoteMelt, + usedKeysets: [keyset], + token: 'tok-$i', + proofPubKeys: proofPks, + ); + + final encoded = jsonEncode(WalletTransactionModel.toJson(tx)); + final decoded = jsonDecode(encoded) as Map; + + final restored = + WalletTransactionModel.fromJson(decoded) as CashuWalletTransaction; + + expect(restored.id, equals(tx.id)); + expect(restored.walletId, equals(tx.walletId)); + expect(restored.mintUrl, equals(tx.mintUrl)); + expect(restored.note, equals(tx.note)); + expect(restored.method, equals(tx.method)); + expect(restored.token, equals(tx.token)); + expect(restored.proofPubKeys, equals(tx.proofPubKeys)); + expect(restored.usedKeysets?.first.id, equals(keyset.id)); + expect(restored.qoute?.quoteId, equals(quote.quoteId)); + expect(restored.qoute?.amount, equals(quote.amount)); + expect(restored.qouteMelt?.quoteId, equals(quoteMelt.quoteId)); + } + }); +} diff --git a/packages/ndk_flutter/lib/l10n/app_de.arb b/packages/ndk_flutter/lib/l10n/app_de.arb index c366fba14..b94496116 100644 --- a/packages/ndk_flutter/lib/l10n/app_de.arb +++ b/packages/ndk_flutter/lib/l10n/app_de.arb @@ -319,6 +319,13 @@ "pay": "Bezahlen", "create": "Erstellen", "pendingTransactions": "Ausstehend", + "backupSeedWarning": "Sichere deine Cashu-Wiederherstellungsphrase", + "backupSeedTitle": "Cashu-Wiederherstellungsphrase sichern", + "backupSeedInstructions": "Schreibe diese Wörter der Reihe nach auf und bewahre sie sicher auf. Sie sind die einzige Möglichkeit, deine Cashu-Gelder wiederherzustellen, falls du dieses Gerät verlierst.", + "backupSeedConfirm": "Ich habe meine Wiederherstellungsphrase aufgeschrieben und sicher aufbewahrt", + "backupSeedDone": "Ich habe sie gesichert", + "reclaimPendingFunds": "Ausstehende Gelder einlösen", + "reclaimPendingTitle": "Ausstehende Gelder einlösen", "recentTransactions": "Letzte Transaktionen", "noRecentTransactions": "Keine aktuellen Transaktionen", "noWalletsYet": "Noch keine Wallets", @@ -366,8 +373,8 @@ "chooseWalletType": "Wallet-Typ wählen", "nwcWalletTypeTitle": "Nostr Wallet Connect", "nwcWalletTypeSubtitle": "Mit einer entfernten Wallet über NWC verbinden", - "lnurlWalletTypeTitle": "LNURL / Lightning-Adresse", - "lnurlWalletTypeSubtitle": "Eine custodial Wallet mit LNURL oder einer Lightning-Adresse verwenden", + "lnurlWalletTypeTitle": "Lightning-Adresse (LNURL)", + "lnurlWalletTypeSubtitle": "Eine Lightning-Adresse (LNURL) nur zum Empfangen verwenden", "cashuWalletTypeTitle": "Cashu", "cashuWalletTypeSubtitle": "Eine Ecash-Wallet mit einer Cashu-Mint verwenden", "cashuOption": "Cashu", diff --git a/packages/ndk_flutter/lib/l10n/app_en.arb b/packages/ndk_flutter/lib/l10n/app_en.arb index 5c9b9bc40..f177f9f6e 100644 --- a/packages/ndk_flutter/lib/l10n/app_en.arb +++ b/packages/ndk_flutter/lib/l10n/app_en.arb @@ -1153,6 +1153,19 @@ "description": "Label for create button" }, "pendingTransactions": "Pending", + "backupSeedWarning": "Back up your cashu recovery phrase", + "backupSeedTitle": "Back up cashu recovery phrase", + "backupSeedInstructions": "Write down these words in order and store them somewhere safe. They are the only way to recover your cashu funds if you lose this device.", + "backupSeedConfirm": "I have written down my recovery phrase and stored it safely", + "backupSeedDone": "I've backed it up", + "reclaimPendingFunds": "Reclaim pending funds", + "reclaimPendingTitle": "Reclaim Pending Funds", + "@reclaimPendingFunds": { + "description": "Label for the action that retries minting tokens for pending funding transactions" + }, + "@reclaimPendingTitle": { + "description": "Title for the reclaim pending funds dialog" + }, "@pendingTransactions": { "description": "Title for pending transactions section" }, @@ -1272,11 +1285,11 @@ "@nwcWalletTypeSubtitle": { "description": "Subtitle for the NWC wallet type option" }, - "lnurlWalletTypeTitle": "LNURL / Lightning Address", + "lnurlWalletTypeTitle": "Lightning Address (LNURL)", "@lnurlWalletTypeTitle": { "description": "Title for the LNURL wallet type option" }, - "lnurlWalletTypeSubtitle": "Use a custodial wallet with LNURL or a Lightning address", + "lnurlWalletTypeSubtitle": "Use a Lightning address (LNURL) for receiving only", "@lnurlWalletTypeSubtitle": { "description": "Subtitle for the LNURL wallet type option" }, @@ -1425,5 +1438,63 @@ "budgetNever": "Never", "@budgetNever": { "description": "Never renews budget" + }, + "backup": "Backup", + "@backup": { + "description": "Label for the wallet backup action" + }, + "restore": "Restore", + "@restore": { + "description": "Label for the wallet restore action" + }, + "cashuBackupTitle": "Cashu Backup", + "@cashuBackupTitle": { + "description": "Title of the cashu backup dialog" + }, + "cashuBackupWarning": "This backup contains your ecash proofs, which are bearer funds. Keep it private and store it somewhere safe. Your seed phrase is backed up separately.", + "@cashuBackupWarning": { + "description": "Security warning shown in the cashu backup dialog" + }, + "generatingBackup": "Generating backup...", + "@generatingBackup": { + "description": "Shown while the cashu backup is being generated" + }, + "copyBackup": "Copy backup", + "@copyBackup": { + "description": "Button to copy the cashu backup to the clipboard" + }, + "backupCopiedToClipboard": "Backup copied to clipboard", + "@backupCopiedToClipboard": { + "description": "Confirmation that the backup was copied" + }, + "cashuRestoreTitle": "Restore Cashu Backup", + "@cashuRestoreTitle": { + "description": "Title of the cashu restore dialog" + }, + "backupJson": "Backup JSON", + "@backupJson": { + "description": "Label for the backup JSON input field" + }, + "backupJsonHint": "Paste your backup JSON here", + "@backupJsonHint": { + "description": "Hint for the backup JSON input field" + }, + "pleaseEnterBackup": "Please enter a backup", + "@pleaseEnterBackup": { + "description": "Validation message when the backup field is empty" + }, + "restoringBackup": "Restoring backup...", + "@restoringBackup": { + "description": "Shown while a cashu backup is being restored" + }, + "restoreSuccess": "Restored {count} proofs from backup", + "@restoreSuccess": { + "description": "Confirmation after a successful restore", + "placeholders": { + "count": { + "type": "int", + "description": "Number of restored proofs" + } + } } } diff --git a/packages/ndk_flutter/lib/l10n/app_es.arb b/packages/ndk_flutter/lib/l10n/app_es.arb index f07fab16c..2da1615db 100644 --- a/packages/ndk_flutter/lib/l10n/app_es.arb +++ b/packages/ndk_flutter/lib/l10n/app_es.arb @@ -283,6 +283,13 @@ "pay": "Pagar", "create": "Crear", "pendingTransactions": "Pendientes", + "backupSeedWarning": "Haz una copia de tu frase de recuperación cashu", + "backupSeedTitle": "Copia de la frase de recuperación cashu", + "backupSeedInstructions": "Escribe estas palabras en orden y guárdalas en un lugar seguro. Son la única forma de recuperar tus fondos cashu si pierdes este dispositivo.", + "backupSeedConfirm": "He anotado mi frase de recuperación y la he guardado de forma segura", + "backupSeedDone": "Ya hice la copia", + "reclaimPendingFunds": "Recuperar fondos pendientes", + "reclaimPendingTitle": "Recuperar fondos pendientes", "recentTransactions": "Transacciones Recientes", "noRecentTransactions": "No hay transacciones recientes", "noWalletsYet": "No hay carteras aún", @@ -308,8 +315,8 @@ "chooseWalletType": "Elegir tipo de cartera", "nwcWalletTypeTitle": "Nostr Wallet Connect", "nwcWalletTypeSubtitle": "Conectarse a una cartera remota con NWC", - "lnurlWalletTypeTitle": "LNURL / Direccion Lightning", - "lnurlWalletTypeSubtitle": "Usar una cartera custodial con LNURL o una direccion Lightning", + "lnurlWalletTypeTitle": "Direccion Lightning (LNURL)", + "lnurlWalletTypeSubtitle": "Usar una direccion Lightning (LNURL) solo para recibir", "cashuWalletTypeTitle": "Cashu", "cashuWalletTypeSubtitle": "Usar una cartera ecash respaldada por una mint de Cashu", "cashuOption": "Cashu", diff --git a/packages/ndk_flutter/lib/l10n/app_fi.arb b/packages/ndk_flutter/lib/l10n/app_fi.arb index b83f118f7..c36f49866 100644 --- a/packages/ndk_flutter/lib/l10n/app_fi.arb +++ b/packages/ndk_flutter/lib/l10n/app_fi.arb @@ -319,6 +319,13 @@ "pay": "Maksa", "create": "Luo", "pendingTransactions": "Odottaa", + "backupSeedWarning": "Varmuuskopioi cashu-palautuslause", + "backupSeedTitle": "Varmuuskopioi cashu-palautuslause", + "backupSeedInstructions": "Kirjoita nämä sanat järjestyksessä ylös ja säilytä ne turvallisessa paikassa. Ne ovat ainoa tapa palauttaa cashu-varasi, jos menetät tämän laitteen.", + "backupSeedConfirm": "Olen kirjoittanut palautuslauseen ylös ja tallentanut sen turvallisesti", + "backupSeedDone": "Olen varmuuskopioinut sen", + "reclaimPendingFunds": "Lunasta odottavat varat", + "reclaimPendingTitle": "Lunasta odottavat varat", "recentTransactions": "Viimeaikaiset tapahtumat", "noRecentTransactions": "Ei viimeaikaisia tapahtumia", "noWalletsYet": "Ei lompakoita vielä", @@ -366,8 +373,8 @@ "chooseWalletType": "Valitse lompakon tyyppi", "nwcWalletTypeTitle": "Nostr Wallet Connect", "nwcWalletTypeSubtitle": "Yhdistä etälompakkoon NWC:llä", - "lnurlWalletTypeTitle": "LNURL / Lightning-osoite", - "lnurlWalletTypeSubtitle": "Käytä hallinnoitua lompakkoa LNURL:llä tai Lightning-osoitteella", + "lnurlWalletTypeTitle": "Lightning-osoite (LNURL)", + "lnurlWalletTypeSubtitle": "Käytä Lightning-osoitetta (LNURL) vain vastaanottamiseen", "cashuWalletTypeTitle": "Cashu", "cashuWalletTypeSubtitle": "Käytä ecash-lompakkoa Cashu-mintin tukemana", "cashuOption": "Cashu", diff --git a/packages/ndk_flutter/lib/l10n/app_fr.arb b/packages/ndk_flutter/lib/l10n/app_fr.arb index 30c9eab98..ebe708f92 100644 --- a/packages/ndk_flutter/lib/l10n/app_fr.arb +++ b/packages/ndk_flutter/lib/l10n/app_fr.arb @@ -283,6 +283,13 @@ "pay": "Payer", "create": "Créer", "pendingTransactions": "En Attente", + "backupSeedWarning": "Sauvegardez votre phrase de récupération cashu", + "backupSeedTitle": "Sauvegarder la phrase de récupération cashu", + "backupSeedInstructions": "Notez ces mots dans l'ordre et conservez-les en lieu sûr. C'est le seul moyen de récupérer vos fonds cashu si vous perdez cet appareil.", + "backupSeedConfirm": "J'ai noté ma phrase de récupération et l'ai conservée en lieu sûr", + "backupSeedDone": "Je l'ai sauvegardée", + "reclaimPendingFunds": "Récupérer les fonds en attente", + "reclaimPendingTitle": "Récupérer les fonds en attente", "recentTransactions": "Transactions Récentes", "noRecentTransactions": "Aucune transaction récente", "noWalletsYet": "Aucun portefeuille encore", @@ -308,8 +315,8 @@ "chooseWalletType": "Choisir le type de portefeuille", "nwcWalletTypeTitle": "Nostr Wallet Connect", "nwcWalletTypeSubtitle": "Connecter un portefeuille distant avec NWC", - "lnurlWalletTypeTitle": "LNURL / Adresse Lightning", - "lnurlWalletTypeSubtitle": "Utiliser un portefeuille custodial avec LNURL ou une adresse Lightning", + "lnurlWalletTypeTitle": "Adresse Lightning (LNURL)", + "lnurlWalletTypeSubtitle": "Utiliser une adresse Lightning (LNURL) pour la réception uniquement", "cashuWalletTypeTitle": "Cashu", "cashuWalletTypeSubtitle": "Utiliser un portefeuille ecash adosse a une mint Cashu", "cashuOption": "Cashu", diff --git a/packages/ndk_flutter/lib/l10n/app_it.arb b/packages/ndk_flutter/lib/l10n/app_it.arb index d8fe82e2a..277240d62 100644 --- a/packages/ndk_flutter/lib/l10n/app_it.arb +++ b/packages/ndk_flutter/lib/l10n/app_it.arb @@ -319,6 +319,13 @@ "pay": "Paga", "create": "Crea", "pendingTransactions": "In sospeso", + "backupSeedWarning": "Esegui il backup della frase di recupero cashu", + "backupSeedTitle": "Backup della frase di recupero cashu", + "backupSeedInstructions": "Annota queste parole in ordine e conservale in un luogo sicuro. Sono l'unico modo per recuperare i tuoi fondi cashu se perdi questo dispositivo.", + "backupSeedConfirm": "Ho annotato la mia frase di recupero e l'ho conservata in modo sicuro", + "backupSeedDone": "Ho fatto il backup", + "reclaimPendingFunds": "Recupera fondi in sospeso", + "reclaimPendingTitle": "Recupera fondi in sospeso", "recentTransactions": "Transazioni recenti", "noRecentTransactions": "Nessuna transazione recente", "noWalletsYet": "Nessun portafoglio", @@ -366,8 +373,8 @@ "chooseWalletType": "Scegli il tipo di portafoglio", "nwcWalletTypeTitle": "Nostr Wallet Connect", "nwcWalletTypeSubtitle": "Connetti un portafoglio remoto con NWC", - "lnurlWalletTypeTitle": "LNURL / Indirizzo Lightning", - "lnurlWalletTypeSubtitle": "Usa un portafoglio custodial con LNURL o un indirizzo Lightning", + "lnurlWalletTypeTitle": "Indirizzo Lightning (LNURL)", + "lnurlWalletTypeSubtitle": "Usa un indirizzo Lightning (LNURL) solo per ricevere", "cashuWalletTypeTitle": "Cashu", "cashuWalletTypeSubtitle": "Usa un portafoglio ecash basato su una mint Cashu", "cashuOption": "Cashu", diff --git a/packages/ndk_flutter/lib/l10n/app_ja.arb b/packages/ndk_flutter/lib/l10n/app_ja.arb index efbf7f105..03a33e7cd 100644 --- a/packages/ndk_flutter/lib/l10n/app_ja.arb +++ b/packages/ndk_flutter/lib/l10n/app_ja.arb @@ -283,6 +283,13 @@ "pay": "支払う", "create": "作成", "pendingTransactions": "保留中", + "backupSeedWarning": "Cashuリカバリーフレーズをバックアップしてください", + "backupSeedTitle": "Cashuリカバリーフレーズのバックアップ", + "backupSeedInstructions": "これらの単語を順番に書き留め、安全な場所に保管してください。この端末を紛失した場合、cashu資金を復元する唯一の方法です。", + "backupSeedConfirm": "リカバリーフレーズを書き留め、安全に保管しました", + "backupSeedDone": "バックアップしました", + "reclaimPendingFunds": "保留中の資金を回収", + "reclaimPendingTitle": "保留中の資金を回収", "recentTransactions": "最近の取引", "noRecentTransactions": "最近の取引はありません", "noWalletsYet": "ウォレットはまだありません", @@ -308,8 +315,8 @@ "chooseWalletType": "ウォレットタイプを選択", "nwcWalletTypeTitle": "Nostr Wallet Connect", "nwcWalletTypeSubtitle": "NWCでリモートウォレットに接続", - "lnurlWalletTypeTitle": "LNURL / Lightning Address", - "lnurlWalletTypeSubtitle": "LNURLまたはLightning Address対応ウォレットを使う", + "lnurlWalletTypeTitle": "Lightningアドレス(LNURL)", + "lnurlWalletTypeSubtitle": "受信専用にLightningアドレス(LNURL)を使う", "cashuWalletTypeTitle": "Cashu", "cashuWalletTypeSubtitle": "Cashuミント対応のecashウォレットを使う", "cashuOption": "Cashu", diff --git a/packages/ndk_flutter/lib/l10n/app_localizations.dart b/packages/ndk_flutter/lib/l10n/app_localizations.dart index 14b432684..4841fe38d 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations.dart @@ -1815,6 +1815,48 @@ abstract class AppLocalizations { /// **'Pending'** String get pendingTransactions; + /// No description provided for @backupSeedWarning. + /// + /// In en, this message translates to: + /// **'Back up your cashu recovery phrase'** + String get backupSeedWarning; + + /// No description provided for @backupSeedTitle. + /// + /// In en, this message translates to: + /// **'Back up cashu recovery phrase'** + String get backupSeedTitle; + + /// No description provided for @backupSeedInstructions. + /// + /// In en, this message translates to: + /// **'Write down these words in order and store them somewhere safe. They are the only way to recover your cashu funds if you lose this device.'** + String get backupSeedInstructions; + + /// No description provided for @backupSeedConfirm. + /// + /// In en, this message translates to: + /// **'I have written down my recovery phrase and stored it safely'** + String get backupSeedConfirm; + + /// No description provided for @backupSeedDone. + /// + /// In en, this message translates to: + /// **'I\'ve backed it up'** + String get backupSeedDone; + + /// Label for the action that retries minting tokens for pending funding transactions + /// + /// In en, this message translates to: + /// **'Reclaim pending funds'** + String get reclaimPendingFunds; + + /// Title for the reclaim pending funds dialog + /// + /// In en, this message translates to: + /// **'Reclaim Pending Funds'** + String get reclaimPendingTitle; + /// Title for recent transactions section /// /// In en, this message translates to: @@ -1968,13 +2010,13 @@ abstract class AppLocalizations { /// Title for the LNURL wallet type option /// /// In en, this message translates to: - /// **'LNURL / Lightning Address'** + /// **'Lightning Address (LNURL)'** String get lnurlWalletTypeTitle; /// Subtitle for the LNURL wallet type option /// /// In en, this message translates to: - /// **'Use a custodial wallet with LNURL or a Lightning address'** + /// **'Use a Lightning address (LNURL) for receiving only'** String get lnurlWalletTypeSubtitle; /// Title for the Cashu wallet type option @@ -2168,6 +2210,84 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Never'** String get budgetNever; + + /// Label for the wallet backup action + /// + /// In en, this message translates to: + /// **'Backup'** + String get backup; + + /// Label for the wallet restore action + /// + /// In en, this message translates to: + /// **'Restore'** + String get restore; + + /// Title of the cashu backup dialog + /// + /// In en, this message translates to: + /// **'Cashu Backup'** + String get cashuBackupTitle; + + /// Security warning shown in the cashu backup dialog + /// + /// In en, this message translates to: + /// **'This backup contains your ecash proofs, which are bearer funds. Keep it private and store it somewhere safe. Your seed phrase is backed up separately.'** + String get cashuBackupWarning; + + /// Shown while the cashu backup is being generated + /// + /// In en, this message translates to: + /// **'Generating backup...'** + String get generatingBackup; + + /// Button to copy the cashu backup to the clipboard + /// + /// In en, this message translates to: + /// **'Copy backup'** + String get copyBackup; + + /// Confirmation that the backup was copied + /// + /// In en, this message translates to: + /// **'Backup copied to clipboard'** + String get backupCopiedToClipboard; + + /// Title of the cashu restore dialog + /// + /// In en, this message translates to: + /// **'Restore Cashu Backup'** + String get cashuRestoreTitle; + + /// Label for the backup JSON input field + /// + /// In en, this message translates to: + /// **'Backup JSON'** + String get backupJson; + + /// Hint for the backup JSON input field + /// + /// In en, this message translates to: + /// **'Paste your backup JSON here'** + String get backupJsonHint; + + /// Validation message when the backup field is empty + /// + /// In en, this message translates to: + /// **'Please enter a backup'** + String get pleaseEnterBackup; + + /// Shown while a cashu backup is being restored + /// + /// In en, this message translates to: + /// **'Restoring backup...'** + String get restoringBackup; + + /// Confirmation after a successful restore + /// + /// In en, this message translates to: + /// **'Restored {count} proofs from backup'** + String restoreSuccess(int count); } class _AppLocalizationsDelegate diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_de.dart b/packages/ndk_flutter/lib/l10n/app_localizations_de.dart index a11e26516..4e456c5de 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_de.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_de.dart @@ -872,6 +872,30 @@ class AppLocalizationsDe extends AppLocalizations { @override String get pendingTransactions => 'Ausstehend'; + @override + String get backupSeedWarning => + 'Sichere deine Cashu-Wiederherstellungsphrase'; + + @override + String get backupSeedTitle => 'Cashu-Wiederherstellungsphrase sichern'; + + @override + String get backupSeedInstructions => + 'Schreibe diese Wörter der Reihe nach auf und bewahre sie sicher auf. Sie sind die einzige Möglichkeit, deine Cashu-Gelder wiederherzustellen, falls du dieses Gerät verlierst.'; + + @override + String get backupSeedConfirm => + 'Ich habe meine Wiederherstellungsphrase aufgeschrieben und sicher aufbewahrt'; + + @override + String get backupSeedDone => 'Ich habe sie gesichert'; + + @override + String get reclaimPendingFunds => 'Ausstehende Gelder einlösen'; + + @override + String get reclaimPendingTitle => 'Ausstehende Gelder einlösen'; + @override String get recentTransactions => 'Letzte Transaktionen'; @@ -954,11 +978,11 @@ class AppLocalizationsDe extends AppLocalizations { 'Mit einer entfernten Wallet über NWC verbinden'; @override - String get lnurlWalletTypeTitle => 'LNURL / Lightning-Adresse'; + String get lnurlWalletTypeTitle => 'Lightning-Adresse (LNURL)'; @override String get lnurlWalletTypeSubtitle => - 'Eine custodial Wallet mit LNURL oder einer Lightning-Adresse verwenden'; + 'Eine Lightning-Adresse (LNURL) nur zum Empfangen verwenden'; @override String get cashuWalletTypeTitle => 'Cashu'; @@ -1069,4 +1093,46 @@ class AppLocalizationsDe extends AppLocalizations { @override String get budgetNever => 'Nie'; + + @override + String get backup => 'Backup'; + + @override + String get restore => 'Restore'; + + @override + String get cashuBackupTitle => 'Cashu Backup'; + + @override + String get cashuBackupWarning => + 'This backup contains your ecash proofs, which are bearer funds. Keep it private and store it somewhere safe. Your seed phrase is backed up separately.'; + + @override + String get generatingBackup => 'Generating backup...'; + + @override + String get copyBackup => 'Copy backup'; + + @override + String get backupCopiedToClipboard => 'Backup copied to clipboard'; + + @override + String get cashuRestoreTitle => 'Restore Cashu Backup'; + + @override + String get backupJson => 'Backup JSON'; + + @override + String get backupJsonHint => 'Paste your backup JSON here'; + + @override + String get pleaseEnterBackup => 'Please enter a backup'; + + @override + String get restoringBackup => 'Restoring backup...'; + + @override + String restoreSuccess(int count) { + return 'Restored $count proofs from backup'; + } } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_en.dart b/packages/ndk_flutter/lib/l10n/app_localizations_en.dart index 09e888f98..c0f8fc764 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_en.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_en.dart @@ -871,6 +871,29 @@ class AppLocalizationsEn extends AppLocalizations { @override String get pendingTransactions => 'Pending'; + @override + String get backupSeedWarning => 'Back up your cashu recovery phrase'; + + @override + String get backupSeedTitle => 'Back up cashu recovery phrase'; + + @override + String get backupSeedInstructions => + 'Write down these words in order and store them somewhere safe. They are the only way to recover your cashu funds if you lose this device.'; + + @override + String get backupSeedConfirm => + 'I have written down my recovery phrase and stored it safely'; + + @override + String get backupSeedDone => 'I\'ve backed it up'; + + @override + String get reclaimPendingFunds => 'Reclaim pending funds'; + + @override + String get reclaimPendingTitle => 'Reclaim Pending Funds'; + @override String get recentTransactions => 'Recent Transactions'; @@ -952,11 +975,11 @@ class AppLocalizationsEn extends AppLocalizations { String get nwcWalletTypeSubtitle => 'Connect to a remote wallet with NWC'; @override - String get lnurlWalletTypeTitle => 'LNURL / Lightning Address'; + String get lnurlWalletTypeTitle => 'Lightning Address (LNURL)'; @override String get lnurlWalletTypeSubtitle => - 'Use a custodial wallet with LNURL or a Lightning address'; + 'Use a Lightning address (LNURL) for receiving only'; @override String get cashuWalletTypeTitle => 'Cashu'; @@ -1066,4 +1089,46 @@ class AppLocalizationsEn extends AppLocalizations { @override String get budgetNever => 'Never'; + + @override + String get backup => 'Backup'; + + @override + String get restore => 'Restore'; + + @override + String get cashuBackupTitle => 'Cashu Backup'; + + @override + String get cashuBackupWarning => + 'This backup contains your ecash proofs, which are bearer funds. Keep it private and store it somewhere safe. Your seed phrase is backed up separately.'; + + @override + String get generatingBackup => 'Generating backup...'; + + @override + String get copyBackup => 'Copy backup'; + + @override + String get backupCopiedToClipboard => 'Backup copied to clipboard'; + + @override + String get cashuRestoreTitle => 'Restore Cashu Backup'; + + @override + String get backupJson => 'Backup JSON'; + + @override + String get backupJsonHint => 'Paste your backup JSON here'; + + @override + String get pleaseEnterBackup => 'Please enter a backup'; + + @override + String get restoringBackup => 'Restoring backup...'; + + @override + String restoreSuccess(int count) { + return 'Restored $count proofs from backup'; + } } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_es.dart b/packages/ndk_flutter/lib/l10n/app_localizations_es.dart index efd0b0846..cee5b3ee6 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_es.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_es.dart @@ -874,6 +874,30 @@ class AppLocalizationsEs extends AppLocalizations { @override String get pendingTransactions => 'Pendientes'; + @override + String get backupSeedWarning => + 'Haz una copia de tu frase de recuperación cashu'; + + @override + String get backupSeedTitle => 'Copia de la frase de recuperación cashu'; + + @override + String get backupSeedInstructions => + 'Escribe estas palabras en orden y guárdalas en un lugar seguro. Son la única forma de recuperar tus fondos cashu si pierdes este dispositivo.'; + + @override + String get backupSeedConfirm => + 'He anotado mi frase de recuperación y la he guardado de forma segura'; + + @override + String get backupSeedDone => 'Ya hice la copia'; + + @override + String get reclaimPendingFunds => 'Recuperar fondos pendientes'; + + @override + String get reclaimPendingTitle => 'Recuperar fondos pendientes'; + @override String get recentTransactions => 'Transacciones Recientes'; @@ -955,11 +979,11 @@ class AppLocalizationsEs extends AppLocalizations { String get nwcWalletTypeSubtitle => 'Conectarse a una cartera remota con NWC'; @override - String get lnurlWalletTypeTitle => 'LNURL / Direccion Lightning'; + String get lnurlWalletTypeTitle => 'Direccion Lightning (LNURL)'; @override String get lnurlWalletTypeSubtitle => - 'Usar una cartera custodial con LNURL o una direccion Lightning'; + 'Usar una direccion Lightning (LNURL) solo para recibir'; @override String get cashuWalletTypeTitle => 'Cashu'; @@ -1070,4 +1094,46 @@ class AppLocalizationsEs extends AppLocalizations { @override String get budgetNever => 'Nunca'; + + @override + String get backup => 'Backup'; + + @override + String get restore => 'Restore'; + + @override + String get cashuBackupTitle => 'Cashu Backup'; + + @override + String get cashuBackupWarning => + 'This backup contains your ecash proofs, which are bearer funds. Keep it private and store it somewhere safe. Your seed phrase is backed up separately.'; + + @override + String get generatingBackup => 'Generating backup...'; + + @override + String get copyBackup => 'Copy backup'; + + @override + String get backupCopiedToClipboard => 'Backup copied to clipboard'; + + @override + String get cashuRestoreTitle => 'Restore Cashu Backup'; + + @override + String get backupJson => 'Backup JSON'; + + @override + String get backupJsonHint => 'Paste your backup JSON here'; + + @override + String get pleaseEnterBackup => 'Please enter a backup'; + + @override + String get restoringBackup => 'Restoring backup...'; + + @override + String restoreSuccess(int count) { + return 'Restored $count proofs from backup'; + } } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart b/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart index 2c5496f30..1eb51ff15 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart @@ -872,6 +872,29 @@ class AppLocalizationsFi extends AppLocalizations { @override String get pendingTransactions => 'Odottaa'; + @override + String get backupSeedWarning => 'Varmuuskopioi cashu-palautuslause'; + + @override + String get backupSeedTitle => 'Varmuuskopioi cashu-palautuslause'; + + @override + String get backupSeedInstructions => + 'Kirjoita nämä sanat järjestyksessä ylös ja säilytä ne turvallisessa paikassa. Ne ovat ainoa tapa palauttaa cashu-varasi, jos menetät tämän laitteen.'; + + @override + String get backupSeedConfirm => + 'Olen kirjoittanut palautuslauseen ylös ja tallentanut sen turvallisesti'; + + @override + String get backupSeedDone => 'Olen varmuuskopioinut sen'; + + @override + String get reclaimPendingFunds => 'Lunasta odottavat varat'; + + @override + String get reclaimPendingTitle => 'Lunasta odottavat varat'; + @override String get recentTransactions => 'Viimeaikaiset tapahtumat'; @@ -953,11 +976,11 @@ class AppLocalizationsFi extends AppLocalizations { String get nwcWalletTypeSubtitle => 'Yhdistä etälompakkoon NWC:llä'; @override - String get lnurlWalletTypeTitle => 'LNURL / Lightning-osoite'; + String get lnurlWalletTypeTitle => 'Lightning-osoite (LNURL)'; @override String get lnurlWalletTypeSubtitle => - 'Käytä hallinnoitua lompakkoa LNURL:llä tai Lightning-osoitteella'; + 'Käytä Lightning-osoitetta (LNURL) vain vastaanottamiseen'; @override String get cashuWalletTypeTitle => 'Cashu'; @@ -1068,4 +1091,46 @@ class AppLocalizationsFi extends AppLocalizations { @override String get budgetNever => 'Ei koskaan'; + + @override + String get backup => 'Backup'; + + @override + String get restore => 'Restore'; + + @override + String get cashuBackupTitle => 'Cashu Backup'; + + @override + String get cashuBackupWarning => + 'This backup contains your ecash proofs, which are bearer funds. Keep it private and store it somewhere safe. Your seed phrase is backed up separately.'; + + @override + String get generatingBackup => 'Generating backup...'; + + @override + String get copyBackup => 'Copy backup'; + + @override + String get backupCopiedToClipboard => 'Backup copied to clipboard'; + + @override + String get cashuRestoreTitle => 'Restore Cashu Backup'; + + @override + String get backupJson => 'Backup JSON'; + + @override + String get backupJsonHint => 'Paste your backup JSON here'; + + @override + String get pleaseEnterBackup => 'Please enter a backup'; + + @override + String get restoringBackup => 'Restoring backup...'; + + @override + String restoreSuccess(int count) { + return 'Restored $count proofs from backup'; + } } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart b/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart index 8405372f4..f342c8c24 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart @@ -873,6 +873,30 @@ class AppLocalizationsFr extends AppLocalizations { @override String get pendingTransactions => 'En Attente'; + @override + String get backupSeedWarning => + 'Sauvegardez votre phrase de récupération cashu'; + + @override + String get backupSeedTitle => 'Sauvegarder la phrase de récupération cashu'; + + @override + String get backupSeedInstructions => + 'Notez ces mots dans l\'ordre et conservez-les en lieu sûr. C\'est le seul moyen de récupérer vos fonds cashu si vous perdez cet appareil.'; + + @override + String get backupSeedConfirm => + 'J\'ai noté ma phrase de récupération et l\'ai conservée en lieu sûr'; + + @override + String get backupSeedDone => 'Je l\'ai sauvegardée'; + + @override + String get reclaimPendingFunds => 'Récupérer les fonds en attente'; + + @override + String get reclaimPendingTitle => 'Récupérer les fonds en attente'; + @override String get recentTransactions => 'Transactions Récentes'; @@ -955,11 +979,11 @@ class AppLocalizationsFr extends AppLocalizations { 'Connecter un portefeuille distant avec NWC'; @override - String get lnurlWalletTypeTitle => 'LNURL / Adresse Lightning'; + String get lnurlWalletTypeTitle => 'Adresse Lightning (LNURL)'; @override String get lnurlWalletTypeSubtitle => - 'Utiliser un portefeuille custodial avec LNURL ou une adresse Lightning'; + 'Utiliser une adresse Lightning (LNURL) pour la réception uniquement'; @override String get cashuWalletTypeTitle => 'Cashu'; @@ -1070,4 +1094,46 @@ class AppLocalizationsFr extends AppLocalizations { @override String get budgetNever => 'Jamais'; + + @override + String get backup => 'Backup'; + + @override + String get restore => 'Restore'; + + @override + String get cashuBackupTitle => 'Cashu Backup'; + + @override + String get cashuBackupWarning => + 'This backup contains your ecash proofs, which are bearer funds. Keep it private and store it somewhere safe. Your seed phrase is backed up separately.'; + + @override + String get generatingBackup => 'Generating backup...'; + + @override + String get copyBackup => 'Copy backup'; + + @override + String get backupCopiedToClipboard => 'Backup copied to clipboard'; + + @override + String get cashuRestoreTitle => 'Restore Cashu Backup'; + + @override + String get backupJson => 'Backup JSON'; + + @override + String get backupJsonHint => 'Paste your backup JSON here'; + + @override + String get pleaseEnterBackup => 'Please enter a backup'; + + @override + String get restoringBackup => 'Restoring backup...'; + + @override + String restoreSuccess(int count) { + return 'Restored $count proofs from backup'; + } } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_it.dart b/packages/ndk_flutter/lib/l10n/app_localizations_it.dart index 1a5d377fd..a221a1bfe 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_it.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_it.dart @@ -875,6 +875,30 @@ class AppLocalizationsIt extends AppLocalizations { @override String get pendingTransactions => 'In sospeso'; + @override + String get backupSeedWarning => + 'Esegui il backup della frase di recupero cashu'; + + @override + String get backupSeedTitle => 'Backup della frase di recupero cashu'; + + @override + String get backupSeedInstructions => + 'Annota queste parole in ordine e conservale in un luogo sicuro. Sono l\'unico modo per recuperare i tuoi fondi cashu se perdi questo dispositivo.'; + + @override + String get backupSeedConfirm => + 'Ho annotato la mia frase di recupero e l\'ho conservata in modo sicuro'; + + @override + String get backupSeedDone => 'Ho fatto il backup'; + + @override + String get reclaimPendingFunds => 'Recupera fondi in sospeso'; + + @override + String get reclaimPendingTitle => 'Recupera fondi in sospeso'; + @override String get recentTransactions => 'Transazioni recenti'; @@ -956,11 +980,11 @@ class AppLocalizationsIt extends AppLocalizations { String get nwcWalletTypeSubtitle => 'Connetti un portafoglio remoto con NWC'; @override - String get lnurlWalletTypeTitle => 'LNURL / Indirizzo Lightning'; + String get lnurlWalletTypeTitle => 'Indirizzo Lightning (LNURL)'; @override String get lnurlWalletTypeSubtitle => - 'Usa un portafoglio custodial con LNURL o un indirizzo Lightning'; + 'Usa un indirizzo Lightning (LNURL) solo per ricevere'; @override String get cashuWalletTypeTitle => 'Cashu'; @@ -1071,4 +1095,46 @@ class AppLocalizationsIt extends AppLocalizations { @override String get budgetNever => 'Mai'; + + @override + String get backup => 'Backup'; + + @override + String get restore => 'Restore'; + + @override + String get cashuBackupTitle => 'Cashu Backup'; + + @override + String get cashuBackupWarning => + 'This backup contains your ecash proofs, which are bearer funds. Keep it private and store it somewhere safe. Your seed phrase is backed up separately.'; + + @override + String get generatingBackup => 'Generating backup...'; + + @override + String get copyBackup => 'Copy backup'; + + @override + String get backupCopiedToClipboard => 'Backup copied to clipboard'; + + @override + String get cashuRestoreTitle => 'Restore Cashu Backup'; + + @override + String get backupJson => 'Backup JSON'; + + @override + String get backupJsonHint => 'Paste your backup JSON here'; + + @override + String get pleaseEnterBackup => 'Please enter a backup'; + + @override + String get restoringBackup => 'Restoring backup...'; + + @override + String restoreSuccess(int count) { + return 'Restored $count proofs from backup'; + } } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart b/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart index 63dbfd7ad..321a56932 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart @@ -865,6 +865,28 @@ class AppLocalizationsJa extends AppLocalizations { @override String get pendingTransactions => '保留中'; + @override + String get backupSeedWarning => 'Cashuリカバリーフレーズをバックアップしてください'; + + @override + String get backupSeedTitle => 'Cashuリカバリーフレーズのバックアップ'; + + @override + String get backupSeedInstructions => + 'これらの単語を順番に書き留め、安全な場所に保管してください。この端末を紛失した場合、cashu資金を復元する唯一の方法です。'; + + @override + String get backupSeedConfirm => 'リカバリーフレーズを書き留め、安全に保管しました'; + + @override + String get backupSeedDone => 'バックアップしました'; + + @override + String get reclaimPendingFunds => '保留中の資金を回収'; + + @override + String get reclaimPendingTitle => '保留中の資金を回収'; + @override String get recentTransactions => '最近の取引'; @@ -946,10 +968,10 @@ class AppLocalizationsJa extends AppLocalizations { String get nwcWalletTypeSubtitle => 'NWCでリモートウォレットに接続'; @override - String get lnurlWalletTypeTitle => 'LNURL / Lightning Address'; + String get lnurlWalletTypeTitle => 'Lightningアドレス(LNURL)'; @override - String get lnurlWalletTypeSubtitle => 'LNURLまたはLightning Address対応ウォレットを使う'; + String get lnurlWalletTypeSubtitle => '受信専用にLightningアドレス(LNURL)を使う'; @override String get cashuWalletTypeTitle => 'Cashu'; @@ -1058,4 +1080,46 @@ class AppLocalizationsJa extends AppLocalizations { @override String get budgetNever => 'なし'; + + @override + String get backup => 'Backup'; + + @override + String get restore => 'Restore'; + + @override + String get cashuBackupTitle => 'Cashu Backup'; + + @override + String get cashuBackupWarning => + 'This backup contains your ecash proofs, which are bearer funds. Keep it private and store it somewhere safe. Your seed phrase is backed up separately.'; + + @override + String get generatingBackup => 'Generating backup...'; + + @override + String get copyBackup => 'Copy backup'; + + @override + String get backupCopiedToClipboard => 'Backup copied to clipboard'; + + @override + String get cashuRestoreTitle => 'Restore Cashu Backup'; + + @override + String get backupJson => 'Backup JSON'; + + @override + String get backupJsonHint => 'Paste your backup JSON here'; + + @override + String get pleaseEnterBackup => 'Please enter a backup'; + + @override + String get restoringBackup => 'Restoring backup...'; + + @override + String restoreSuccess(int count) { + return 'Restored $count proofs from backup'; + } } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart b/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart index c7f4aa4ef..5848041bd 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart @@ -875,6 +875,29 @@ class AppLocalizationsPl extends AppLocalizations { @override String get pendingTransactions => 'Oczekujące'; + @override + String get backupSeedWarning => 'Utwórz kopię frazy odzyskiwania cashu'; + + @override + String get backupSeedTitle => 'Kopia frazy odzyskiwania cashu'; + + @override + String get backupSeedInstructions => + 'Zapisz te słowa w kolejności i przechowuj je w bezpiecznym miejscu. To jedyny sposób na odzyskanie środków cashu w razie utraty tego urządzenia.'; + + @override + String get backupSeedConfirm => + 'Zapisałem moją frazę odzyskiwania i bezpiecznie ją przechowuję'; + + @override + String get backupSeedDone => 'Utworzyłem kopię'; + + @override + String get reclaimPendingFunds => 'Odzyskaj oczekujące środki'; + + @override + String get reclaimPendingTitle => 'Odzyskaj oczekujące środki'; + @override String get recentTransactions => 'Ostatnie transakcje'; @@ -956,11 +979,11 @@ class AppLocalizationsPl extends AppLocalizations { String get nwcWalletTypeSubtitle => 'Polacz z portfelem zdalnym przez NWC'; @override - String get lnurlWalletTypeTitle => 'LNURL / Adres Lightning'; + String get lnurlWalletTypeTitle => 'Adres Lightning (LNURL)'; @override String get lnurlWalletTypeSubtitle => - 'Uzyj portfela custodial z LNURL lub adresem Lightning'; + 'Uzyj adresu Lightning (LNURL) tylko do odbierania'; @override String get cashuWalletTypeTitle => 'Cashu'; @@ -1070,4 +1093,46 @@ class AppLocalizationsPl extends AppLocalizations { @override String get budgetNever => 'Nigdy'; + + @override + String get backup => 'Backup'; + + @override + String get restore => 'Restore'; + + @override + String get cashuBackupTitle => 'Cashu Backup'; + + @override + String get cashuBackupWarning => + 'This backup contains your ecash proofs, which are bearer funds. Keep it private and store it somewhere safe. Your seed phrase is backed up separately.'; + + @override + String get generatingBackup => 'Generating backup...'; + + @override + String get copyBackup => 'Copy backup'; + + @override + String get backupCopiedToClipboard => 'Backup copied to clipboard'; + + @override + String get cashuRestoreTitle => 'Restore Cashu Backup'; + + @override + String get backupJson => 'Backup JSON'; + + @override + String get backupJsonHint => 'Paste your backup JSON here'; + + @override + String get pleaseEnterBackup => 'Please enter a backup'; + + @override + String get restoringBackup => 'Restoring backup...'; + + @override + String restoreSuccess(int count) { + return 'Restored $count proofs from backup'; + } } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart b/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart index c4b73a7cc..b23930ed0 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart @@ -877,6 +877,30 @@ class AppLocalizationsPt extends AppLocalizations { @override String get pendingTransactions => 'Pendentes'; + @override + String get backupSeedWarning => + 'Faça backup da sua frase de recuperação cashu'; + + @override + String get backupSeedTitle => 'Backup da frase de recuperação cashu'; + + @override + String get backupSeedInstructions => + 'Anote estas palavras em ordem e guarde-as em um lugar seguro. Elas são a única forma de recuperar seus fundos cashu se você perder este dispositivo.'; + + @override + String get backupSeedConfirm => + 'Anotei minha frase de recuperação e a guardei com segurança'; + + @override + String get backupSeedDone => 'Já fiz o backup'; + + @override + String get reclaimPendingFunds => 'Recuperar fundos pendentes'; + + @override + String get reclaimPendingTitle => 'Recuperar fundos pendentes'; + @override String get recentTransactions => 'Transações recentes'; @@ -958,11 +982,11 @@ class AppLocalizationsPt extends AppLocalizations { String get nwcWalletTypeSubtitle => 'Ligar a uma carteira remota com NWC'; @override - String get lnurlWalletTypeTitle => 'LNURL / Endereço Lightning'; + String get lnurlWalletTypeTitle => 'Endereço Lightning (LNURL)'; @override String get lnurlWalletTypeSubtitle => - 'Use uma carteira custodial com LNURL ou um endereço Lightning'; + 'Use um endereço Lightning (LNURL) apenas para receber'; @override String get cashuWalletTypeTitle => 'Cashu'; @@ -1073,6 +1097,48 @@ class AppLocalizationsPt extends AppLocalizations { @override String get budgetNever => 'Nunca'; + + @override + String get backup => 'Backup'; + + @override + String get restore => 'Restore'; + + @override + String get cashuBackupTitle => 'Cashu Backup'; + + @override + String get cashuBackupWarning => + 'This backup contains your ecash proofs, which are bearer funds. Keep it private and store it somewhere safe. Your seed phrase is backed up separately.'; + + @override + String get generatingBackup => 'Generating backup...'; + + @override + String get copyBackup => 'Copy backup'; + + @override + String get backupCopiedToClipboard => 'Backup copied to clipboard'; + + @override + String get cashuRestoreTitle => 'Restore Cashu Backup'; + + @override + String get backupJson => 'Backup JSON'; + + @override + String get backupJsonHint => 'Paste your backup JSON here'; + + @override + String get pleaseEnterBackup => 'Please enter a backup'; + + @override + String get restoringBackup => 'Restoring backup...'; + + @override + String restoreSuccess(int count) { + return 'Restored $count proofs from backup'; + } } /// The translations for Portuguese, as used in Brazil (`pt_BR`). @@ -1947,6 +2013,30 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get pendingTransactions => 'Pendentes'; + @override + String get backupSeedWarning => + 'Faça backup da sua frase de recuperação cashu'; + + @override + String get backupSeedTitle => 'Backup da frase de recuperação cashu'; + + @override + String get backupSeedInstructions => + 'Anote estas palavras em ordem e guarde-as em um lugar seguro. Elas são a única forma de recuperar seus fundos cashu se você perder este dispositivo.'; + + @override + String get backupSeedConfirm => + 'Anotei minha frase de recuperação e a guardei com segurança'; + + @override + String get backupSeedDone => 'Já fiz o backup'; + + @override + String get reclaimPendingFunds => 'Recuperar fundos pendentes'; + + @override + String get reclaimPendingTitle => 'Recuperar fundos pendentes'; + @override String get recentTransactions => 'Transações recentes'; @@ -2028,11 +2118,11 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { String get nwcWalletTypeSubtitle => 'Conectar a uma carteira remota com NWC'; @override - String get lnurlWalletTypeTitle => 'LNURL / Endereço Lightning'; + String get lnurlWalletTypeTitle => 'Endereço Lightning (LNURL)'; @override String get lnurlWalletTypeSubtitle => - 'Use uma carteira custodial com LNURL ou um endereço Lightning'; + 'Use um endereço Lightning (LNURL) apenas para receber'; @override String get cashuWalletTypeTitle => 'Cashu'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart b/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart index 895c45771..f4236649a 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart @@ -872,6 +872,29 @@ class AppLocalizationsRu extends AppLocalizations { @override String get pendingTransactions => 'В Ожидании'; + @override + String get backupSeedWarning => 'Сохраните вашу cashu-фразу восстановления'; + + @override + String get backupSeedTitle => 'Резервная копия cashu-фразы восстановления'; + + @override + String get backupSeedInstructions => + 'Запишите эти слова по порядку и храните в надёжном месте. Это единственный способ восстановить ваши средства cashu при потере устройства.'; + + @override + String get backupSeedConfirm => + 'Я записал фразу восстановления и надёжно сохранил её'; + + @override + String get backupSeedDone => 'Я сохранил её'; + + @override + String get reclaimPendingFunds => 'Вернуть ожидающие средства'; + + @override + String get reclaimPendingTitle => 'Вернуть ожидающие средства'; + @override String get recentTransactions => 'Недавние Транзакции'; @@ -954,11 +977,11 @@ class AppLocalizationsRu extends AppLocalizations { 'Подключиться к удаленному кошельку через NWC'; @override - String get lnurlWalletTypeTitle => 'LNURL / Lightning-адрес'; + String get lnurlWalletTypeTitle => 'Lightning-адрес (LNURL)'; @override String get lnurlWalletTypeSubtitle => - 'Использовать кастодиальный кошелек с LNURL или Lightning-адресом'; + 'Использовать Lightning-адрес (LNURL) только для получения'; @override String get cashuWalletTypeTitle => 'Cashu'; @@ -1069,4 +1092,46 @@ class AppLocalizationsRu extends AppLocalizations { @override String get budgetNever => 'Никогда'; + + @override + String get backup => 'Backup'; + + @override + String get restore => 'Restore'; + + @override + String get cashuBackupTitle => 'Cashu Backup'; + + @override + String get cashuBackupWarning => + 'This backup contains your ecash proofs, which are bearer funds. Keep it private and store it somewhere safe. Your seed phrase is backed up separately.'; + + @override + String get generatingBackup => 'Generating backup...'; + + @override + String get copyBackup => 'Copy backup'; + + @override + String get backupCopiedToClipboard => 'Backup copied to clipboard'; + + @override + String get cashuRestoreTitle => 'Restore Cashu Backup'; + + @override + String get backupJson => 'Backup JSON'; + + @override + String get backupJsonHint => 'Paste your backup JSON here'; + + @override + String get pleaseEnterBackup => 'Please enter a backup'; + + @override + String get restoringBackup => 'Restoring backup...'; + + @override + String restoreSuccess(int count) { + return 'Restored $count proofs from backup'; + } } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart b/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart index 2e4dc1aa8..42f766cb0 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart @@ -865,6 +865,28 @@ class AppLocalizationsZh extends AppLocalizations { @override String get pendingTransactions => '待处理'; + @override + String get backupSeedWarning => '备份你的 cashu 恢复短语'; + + @override + String get backupSeedTitle => '备份 cashu 恢复短语'; + + @override + String get backupSeedInstructions => + '请按顺序写下这些单词并保存在安全的地方。如果你丢失此设备,这是恢复 cashu 资金的唯一方法。'; + + @override + String get backupSeedConfirm => '我已写下恢复短语并安全保存'; + + @override + String get backupSeedDone => '我已备份'; + + @override + String get reclaimPendingFunds => '找回待处理资金'; + + @override + String get reclaimPendingTitle => '找回待处理资金'; + @override String get recentTransactions => '最近交易'; @@ -945,10 +967,10 @@ class AppLocalizationsZh extends AppLocalizations { String get nwcWalletTypeSubtitle => '通过 NWC 连接远程钱包'; @override - String get lnurlWalletTypeTitle => 'LNURL / Lightning 地址'; + String get lnurlWalletTypeTitle => 'Lightning 地址(LNURL)'; @override - String get lnurlWalletTypeSubtitle => '使用支持 LNURL 或 Lightning 地址的托管钱包'; + String get lnurlWalletTypeSubtitle => '仅使用 Lightning 地址(LNURL)接收'; @override String get cashuWalletTypeTitle => 'Cashu'; @@ -1057,4 +1079,46 @@ class AppLocalizationsZh extends AppLocalizations { @override String get budgetNever => '从不'; + + @override + String get backup => 'Backup'; + + @override + String get restore => 'Restore'; + + @override + String get cashuBackupTitle => 'Cashu Backup'; + + @override + String get cashuBackupWarning => + 'This backup contains your ecash proofs, which are bearer funds. Keep it private and store it somewhere safe. Your seed phrase is backed up separately.'; + + @override + String get generatingBackup => 'Generating backup...'; + + @override + String get copyBackup => 'Copy backup'; + + @override + String get backupCopiedToClipboard => 'Backup copied to clipboard'; + + @override + String get cashuRestoreTitle => 'Restore Cashu Backup'; + + @override + String get backupJson => 'Backup JSON'; + + @override + String get backupJsonHint => 'Paste your backup JSON here'; + + @override + String get pleaseEnterBackup => 'Please enter a backup'; + + @override + String get restoringBackup => 'Restoring backup...'; + + @override + String restoreSuccess(int count) { + return 'Restored $count proofs from backup'; + } } diff --git a/packages/ndk_flutter/lib/l10n/app_pl.arb b/packages/ndk_flutter/lib/l10n/app_pl.arb index 282d46136..714795a16 100644 --- a/packages/ndk_flutter/lib/l10n/app_pl.arb +++ b/packages/ndk_flutter/lib/l10n/app_pl.arb @@ -319,6 +319,13 @@ "pay": "Zapłać", "create": "Utwórz", "pendingTransactions": "Oczekujące", + "backupSeedWarning": "Utwórz kopię frazy odzyskiwania cashu", + "backupSeedTitle": "Kopia frazy odzyskiwania cashu", + "backupSeedInstructions": "Zapisz te słowa w kolejności i przechowuj je w bezpiecznym miejscu. To jedyny sposób na odzyskanie środków cashu w razie utraty tego urządzenia.", + "backupSeedConfirm": "Zapisałem moją frazę odzyskiwania i bezpiecznie ją przechowuję", + "backupSeedDone": "Utworzyłem kopię", + "reclaimPendingFunds": "Odzyskaj oczekujące środki", + "reclaimPendingTitle": "Odzyskaj oczekujące środki", "recentTransactions": "Ostatnie transakcje", "noRecentTransactions": "Brak ostatnich transakcji", "noWalletsYet": "Brak portfeli", @@ -366,8 +373,8 @@ "chooseWalletType": "Wybierz typ portfela", "nwcWalletTypeTitle": "Nostr Wallet Connect", "nwcWalletTypeSubtitle": "Polacz z portfelem zdalnym przez NWC", - "lnurlWalletTypeTitle": "LNURL / Adres Lightning", - "lnurlWalletTypeSubtitle": "Uzyj portfela custodial z LNURL lub adresem Lightning", + "lnurlWalletTypeTitle": "Adres Lightning (LNURL)", + "lnurlWalletTypeSubtitle": "Uzyj adresu Lightning (LNURL) tylko do odbierania", "cashuWalletTypeTitle": "Cashu", "cashuWalletTypeSubtitle": "Uzyj portfela ecash opartego na mennicy Cashu", "cashuOption": "Cashu", diff --git a/packages/ndk_flutter/lib/l10n/app_pt.arb b/packages/ndk_flutter/lib/l10n/app_pt.arb index 9066de071..261858a6e 100644 --- a/packages/ndk_flutter/lib/l10n/app_pt.arb +++ b/packages/ndk_flutter/lib/l10n/app_pt.arb @@ -283,6 +283,13 @@ "pay": "Pagar", "create": "Criar", "pendingTransactions": "Pendentes", + "backupSeedWarning": "Faça backup da sua frase de recuperação cashu", + "backupSeedTitle": "Backup da frase de recuperação cashu", + "backupSeedInstructions": "Anote estas palavras em ordem e guarde-as em um lugar seguro. Elas são a única forma de recuperar seus fundos cashu se você perder este dispositivo.", + "backupSeedConfirm": "Anotei minha frase de recuperação e a guardei com segurança", + "backupSeedDone": "Já fiz o backup", + "reclaimPendingFunds": "Recuperar fundos pendentes", + "reclaimPendingTitle": "Recuperar fundos pendentes", "recentTransactions": "Transações recentes", "noRecentTransactions": "Sem transações recentes", "noWalletsYet": "Ainda não existem carteiras", @@ -308,8 +315,8 @@ "chooseWalletType": "Escolha o tipo de carteira", "nwcWalletTypeTitle": "Nostr Wallet Connect", "nwcWalletTypeSubtitle": "Ligar a uma carteira remota com NWC", - "lnurlWalletTypeTitle": "LNURL / Endereço Lightning", - "lnurlWalletTypeSubtitle": "Use uma carteira custodial com LNURL ou um endereço Lightning", + "lnurlWalletTypeTitle": "Endereço Lightning (LNURL)", + "lnurlWalletTypeSubtitle": "Use um endereço Lightning (LNURL) apenas para receber", "cashuWalletTypeTitle": "Cashu", "cashuWalletTypeSubtitle": "Use uma carteira ecash suportada por uma mint Cashu", "cashuOption": "Cashu", diff --git a/packages/ndk_flutter/lib/l10n/app_pt_BR.arb b/packages/ndk_flutter/lib/l10n/app_pt_BR.arb index 19f40afa9..b63572105 100644 --- a/packages/ndk_flutter/lib/l10n/app_pt_BR.arb +++ b/packages/ndk_flutter/lib/l10n/app_pt_BR.arb @@ -283,6 +283,13 @@ "pay": "Pagar", "create": "Criar", "pendingTransactions": "Pendentes", + "backupSeedWarning": "Faça backup da sua frase de recuperação cashu", + "backupSeedTitle": "Backup da frase de recuperação cashu", + "backupSeedInstructions": "Anote estas palavras em ordem e guarde-as em um lugar seguro. Elas são a única forma de recuperar seus fundos cashu se você perder este dispositivo.", + "backupSeedConfirm": "Anotei minha frase de recuperação e a guardei com segurança", + "backupSeedDone": "Já fiz o backup", + "reclaimPendingFunds": "Recuperar fundos pendentes", + "reclaimPendingTitle": "Recuperar fundos pendentes", "recentTransactions": "Transações recentes", "noRecentTransactions": "Nenhuma transação recente", "noWalletsYet": "Nenhuma carteira ainda", @@ -308,8 +315,8 @@ "chooseWalletType": "Escolha o tipo de carteira", "nwcWalletTypeTitle": "Nostr Wallet Connect", "nwcWalletTypeSubtitle": "Conectar a uma carteira remota com NWC", - "lnurlWalletTypeTitle": "LNURL / Endereço Lightning", - "lnurlWalletTypeSubtitle": "Use uma carteira custodial com LNURL ou um endereço Lightning", + "lnurlWalletTypeTitle": "Endereço Lightning (LNURL)", + "lnurlWalletTypeSubtitle": "Use um endereço Lightning (LNURL) apenas para receber", "cashuWalletTypeTitle": "Cashu", "cashuWalletTypeSubtitle": "Use uma carteira ecash com suporte de uma mint Cashu", "cashuOption": "Cashu", diff --git a/packages/ndk_flutter/lib/l10n/app_ru.arb b/packages/ndk_flutter/lib/l10n/app_ru.arb index a65b35999..e9eada037 100644 --- a/packages/ndk_flutter/lib/l10n/app_ru.arb +++ b/packages/ndk_flutter/lib/l10n/app_ru.arb @@ -283,6 +283,13 @@ "pay": "Оплатить", "create": "Создать", "pendingTransactions": "В Ожидании", + "backupSeedWarning": "Сохраните вашу cashu-фразу восстановления", + "backupSeedTitle": "Резервная копия cashu-фразы восстановления", + "backupSeedInstructions": "Запишите эти слова по порядку и храните в надёжном месте. Это единственный способ восстановить ваши средства cashu при потере устройства.", + "backupSeedConfirm": "Я записал фразу восстановления и надёжно сохранил её", + "backupSeedDone": "Я сохранил её", + "reclaimPendingFunds": "Вернуть ожидающие средства", + "reclaimPendingTitle": "Вернуть ожидающие средства", "recentTransactions": "Недавние Транзакции", "noRecentTransactions": "Нет недавних транзакций", "noWalletsYet": "Пока нет кошельков", @@ -308,8 +315,8 @@ "chooseWalletType": "Выберите тип кошелька", "nwcWalletTypeTitle": "Nostr Wallet Connect", "nwcWalletTypeSubtitle": "Подключиться к удаленному кошельку через NWC", - "lnurlWalletTypeTitle": "LNURL / Lightning-адрес", - "lnurlWalletTypeSubtitle": "Использовать кастодиальный кошелек с LNURL или Lightning-адресом", + "lnurlWalletTypeTitle": "Lightning-адрес (LNURL)", + "lnurlWalletTypeSubtitle": "Использовать Lightning-адрес (LNURL) только для получения", "cashuWalletTypeTitle": "Cashu", "cashuWalletTypeSubtitle": "Использовать ecash-кошелек на базе монетного двора Cashu", "cashuOption": "Cashu", diff --git a/packages/ndk_flutter/lib/l10n/app_zh.arb b/packages/ndk_flutter/lib/l10n/app_zh.arb index 0568fb94b..b646e35e8 100644 --- a/packages/ndk_flutter/lib/l10n/app_zh.arb +++ b/packages/ndk_flutter/lib/l10n/app_zh.arb @@ -283,6 +283,13 @@ "pay": "支付", "create": "创建", "pendingTransactions": "待处理", + "backupSeedWarning": "备份你的 cashu 恢复短语", + "backupSeedTitle": "备份 cashu 恢复短语", + "backupSeedInstructions": "请按顺序写下这些单词并保存在安全的地方。如果你丢失此设备,这是恢复 cashu 资金的唯一方法。", + "backupSeedConfirm": "我已写下恢复短语并安全保存", + "backupSeedDone": "我已备份", + "reclaimPendingFunds": "找回待处理资金", + "reclaimPendingTitle": "找回待处理资金", "recentTransactions": "最近交易", "noRecentTransactions": "无最近交易", "noWalletsYet": "尚无钱包", @@ -308,8 +315,8 @@ "chooseWalletType": "选择钱包类型", "nwcWalletTypeTitle": "Nostr Wallet Connect", "nwcWalletTypeSubtitle": "通过 NWC 连接远程钱包", - "lnurlWalletTypeTitle": "LNURL / Lightning 地址", - "lnurlWalletTypeSubtitle": "使用支持 LNURL 或 Lightning 地址的托管钱包", + "lnurlWalletTypeTitle": "Lightning 地址(LNURL)", + "lnurlWalletTypeSubtitle": "仅使用 Lightning 地址(LNURL)接收", "cashuWalletTypeTitle": "Cashu", "cashuWalletTypeSubtitle": "使用由 Cashu mint 支持的 ecash 钱包", "cashuOption": "Cashu", diff --git a/packages/ndk_flutter/lib/ndk_flutter.dart b/packages/ndk_flutter/lib/ndk_flutter.dart index 4b71738ad..6fa8bbb9c 100644 --- a/packages/ndk_flutter/lib/ndk_flutter.dart +++ b/packages/ndk_flutter/lib/ndk_flutter.dart @@ -1,6 +1,7 @@ export 'main/config.dart'; export 'main/ndk_flutter.dart'; export 'repositories/flutter_secure_storage_wallets_repo.dart'; +export 'repositories/cashu_seed_store.dart'; export 'data_layer/repositories/signers/amber_event_signer.dart'; export 'data_layer/data_sources/amber_flutter.dart'; export 'widgets/widgets.dart'; diff --git a/packages/ndk_flutter/lib/repositories/cashu_seed_store.dart b/packages/ndk_flutter/lib/repositories/cashu_seed_store.dart new file mode 100644 index 000000000..8a4d98d81 --- /dev/null +++ b/packages/ndk_flutter/lib/repositories/cashu_seed_store.dart @@ -0,0 +1,53 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:ndk/domain_layer/entities/cashu/cashu_user_seedphrase.dart'; +import 'package:ndk/ndk.dart'; + +/// Loads the cashu user seed phrase from platform secure storage +/// (Keychain / Keystore), generating and persisting a fresh BIP39 seed phrase +/// on first run. +/// +/// The seed phrase controls cashu funds — never hardcode it. It is written once +/// and reused on every subsequent launch so deterministic secrets keep matching +/// previously minted proofs. +/// +/// Pass the result to [NdkConfig.cashuUserSeedphrase] via [CashuUserSeedphrase]. +class CashuSeedStore { + const CashuSeedStore({FlutterSecureStorage? storage, String? storageKey}) + : _storage = storage ?? const FlutterSecureStorage(), + _seedKey = storageKey ?? _defaultSeedKey; + + final FlutterSecureStorage _storage; + final String _seedKey; + + static const String _defaultSeedKey = 'ndk_flutter_cashu_seed_phrase'; + static const String _backedUpKey = '${_defaultSeedKey}_backed_up'; + + /// Returns the stored seed phrase, or generates, persists and returns a new + /// one if none exists yet. + Future loadOrCreate() async { + final existing = await _storage.read(key: _seedKey); + if (existing != null && existing.trim().isNotEmpty) { + return existing; + } + + final fresh = CashuSeed.generateSeedPhrase(length: MnemonicLength.words12); + await _storage.write(key: _seedKey, value: fresh); + return fresh; + } + + /// Reads the stored seed phrase without generating one. Returns null if none + /// has been stored yet. + Future read() => _storage.read(key: _seedKey); + + /// Overwrites the stored seed phrase, e.g. when restoring from a backup. + Future write(String seedPhrase) => + _storage.write(key: _seedKey, value: seedPhrase); + + /// Whether the user confirmed they have safely backed up the seed phrase. + Future isBackedUp() async => + (await _storage.read(key: _backedUpKey)) == 'true'; + + /// Records whether the user has backed up the seed phrase. + Future setBackedUp(bool value) => + _storage.write(key: _backedUpKey, value: value ? 'true' : 'false'); +} diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_cashu_seed_backup_warning.dart b/packages/ndk_flutter/lib/widgets/wallets/n_cashu_seed_backup_warning.dart new file mode 100644 index 000000000..0d9823a63 --- /dev/null +++ b/packages/ndk_flutter/lib/widgets/wallets/n_cashu_seed_backup_warning.dart @@ -0,0 +1,227 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:ndk/entities.dart'; +import 'package:ndk_flutter/ndk_flutter.dart'; + +import '../../l10n/app_localizations.dart'; + +/// Standalone warning banner shown when at least one cashu wallet exists and +/// the user has not yet confirmed they backed up the cashu recovery (seed) +/// phrase. The seed is global (not per wallet), so place this once in the +/// wallets screen rather than inside each wallet card. +/// +/// Tapping it reveals the words and an explicit confirmation; once confirmed +/// the banner disappears for good. +/// +/// The seed phrase controls all cashu funds — losing it without a backup means +/// the funds cannot be recovered. +class NCashuSeedBackupWarning extends StatefulWidget { + final NdkFlutter ndkFlutter; + + /// Store used to read the backed-up flag. Defaults to [CashuSeedStore] with + /// the default key — pass a custom one if the app configured a custom key. + final CashuSeedStore seedStore; + + const NCashuSeedBackupWarning({ + super.key, + required this.ndkFlutter, + this.seedStore = const CashuSeedStore(), + }); + + @override + State createState() => + _NCashuSeedBackupWarningState(); +} + +class _NCashuSeedBackupWarningState extends State { + late final CashuSeedStore _seedStore = widget.seedStore; + + /// null = loading, true = backed up (hide), false = needs backup (show). + bool? _backedUp; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final backedUp = await _seedStore.isBackedUp(); + if (mounted) setState(() => _backedUp = backedUp); + } + + /// Reads the recovery words from the running NDK instance. Returns an empty + /// list when no seed is configured. + List _seedWords() { + try { + final mnemonic = widget.ndkFlutter.ndk.cashu + .getCashuSeed() + .getSeedPhrase(); + return mnemonic.sentence.trim().split(RegExp(r'\s+')); + } catch (_) { + return const []; + } + } + + Future _showBackupDialog() async { + final l10n = AppLocalizations.of(context)!; + final words = _seedWords(); + if (words.isEmpty) return; + + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + bool checked = false; + return StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + title: Text(l10n.backupSeedTitle), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.backupSeedInstructions), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (int i = 0; i < words.length; i++) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${i + 1}. ${words[i]}', + style: const TextStyle( + fontFamily: 'monospace', + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + icon: const Icon(Icons.copy, size: 18), + label: Text(l10n.copy), + onPressed: () async { + await Clipboard.setData( + ClipboardData(text: words.join(' ')), + ); + if (!dialogContext.mounted) return; + ScaffoldMessenger.of( + dialogContext, + ).showSnackBar(SnackBar(content: Text(l10n.copied))); + }, + ), + ), + const Divider(), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + value: checked, + onChanged: (v) => + setDialogState(() => checked = v ?? false), + title: Text(l10n.backupSeedConfirm), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: checked + ? () => Navigator.of(dialogContext).pop(true) + : null, + child: Text(l10n.backupSeedDone), + ), + ], + ); + }, + ); + }, + ); + + if (confirmed == true) { + await _seedStore.setBackedUp(true); + if (mounted) setState(() => _backedUp = true); + } + } + + @override + Widget build(BuildContext context) { + // Hide while loading, once backed up, or when no seed is configured. + if (_backedUp != false) return const SizedBox.shrink(); + if (_seedWords().isEmpty) return const SizedBox.shrink(); + + // Only warn when at least one cashu wallet actually exists. + return StreamBuilder>( + stream: widget.ndkFlutter.ndk.wallets.walletsStream, + builder: (context, snapshot) { + final hasCashuWallet = + snapshot.data?.any((w) => w is CashuWallet) ?? false; + if (!hasCashuWallet) return const SizedBox.shrink(); + return _buildBanner(context); + }, + ); + } + + Widget _buildBanner(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final scheme = Theme.of(context).colorScheme; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: _showBackupDialog, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.amber.withAlpha(40), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.amber.withAlpha(160)), + ), + child: Row( + children: [ + const Icon( + Icons.warning_amber_rounded, + color: Colors.amber, + size: 22, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + l10n.backupSeedWarning, + style: TextStyle( + color: scheme.onSurface, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + Icon( + Icons.chevron_right, + color: scheme.onSurface.withAlpha(140), + size: 20, + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_pending_transactions.dart b/packages/ndk_flutter/lib/widgets/wallets/n_pending_transactions.dart index a02e5e745..045a0c096 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_pending_transactions.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_pending_transactions.dart @@ -3,6 +3,7 @@ import 'package:ndk/entities.dart'; import 'package:ndk_flutter/ndk_flutter.dart'; import '../../l10n/app_localizations.dart'; +import 'wallet_action_dialogs.dart'; /// Horizontal list of pending wallet transactions for a specific wallet when /// provided. @@ -56,12 +57,29 @@ class NPendingTransactions extends StatelessWidget { } final transactions = snapshot.data!; + final reclaimable = reclaimablePending(transactions); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title ?? l10n.pendingTransactions, - style: Theme.of(context).textTheme.headlineSmall, + Row( + children: [ + Expanded( + child: Text( + title ?? l10n.pendingTransactions, + style: Theme.of(context).textTheme.headlineSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (reclaimable.isNotEmpty) + IconButton( + onPressed: () => + showReclaimDialog(context, ndkFlutter, reclaimable), + icon: const Icon(Icons.replay), + tooltip: l10n.reclaimPendingFunds, + visualDensity: VisualDensity.compact, + ), + ], ), const SizedBox(height: 8), SizedBox( @@ -71,57 +89,80 @@ class NPendingTransactions extends StatelessWidget { itemCount: transactions.length, itemBuilder: (context, index) { final tx = transactions[index]; + // Single-element reclaimable list: non-empty only for cashu + // funding transactions carrying a quote/method/keysets. + final reclaimableTx = reclaimablePending([tx]); return Card( margin: const EdgeInsets.only(right: 8), child: SizedBox( width: 200, - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Row( + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - tx.changeAmount > 0 - ? Icons.download - : Icons.send, - size: 16, - color: tx.changeAmount > 0 - ? Colors.green - : Colors.orange, + Row( + children: [ + Icon( + tx.changeAmount > 0 + ? Icons.download + : Icons.send, + size: 16, + color: tx.changeAmount > 0 + ? Colors.green + : Colors.orange, + ), + const SizedBox(width: 4), + Text( + '${tx.changeAmount.abs()} ${tx.unit}', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + ], ), - const SizedBox(width: 4), + const SizedBox(height: 4), Text( - '${tx.changeAmount.abs()} ${tx.unit}', - style: const TextStyle( - fontWeight: FontWeight.bold, + tx.walletType.name, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 4), + Text( + tx.state.value, + style: TextStyle( + fontSize: 11, + color: + tx.state == + WalletTransactionState.pending + ? Colors.orange + : Colors.grey, ), ), ], ), - const SizedBox(height: 4), - Text( - tx.walletType.name, - style: TextStyle( - fontSize: 12, - color: Colors.grey[600], - ), - ), - const SizedBox(height: 4), - Text( - tx.state.value, - style: TextStyle( - fontSize: 11, - color: - tx.state == WalletTransactionState.pending - ? Colors.orange - : Colors.grey, + ), + if (reclaimableTx.isNotEmpty) + Positioned( + top: 0, + right: 0, + child: IconButton( + icon: const Icon(Icons.replay, size: 18), + tooltip: l10n.reclaimPendingFunds, + visualDensity: VisualDensity.compact, + onPressed: () => showReclaimDialog( + context, + ndkFlutter, + reclaimableTx, + ), ), ), - ], - ), + ], ), ), ); diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_actions.dart b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_actions.dart index 6a31c4d9e..e6184e045 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_actions.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_actions.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:ndk/entities.dart'; import 'package:ndk_flutter/ndk_flutter.dart'; -import 'package:pretty_qr_code/pretty_qr_code.dart'; import '../../l10n/app_localizations.dart'; +import 'wallet_action_dialogs.dart'; /// Card with Send/Receive actions and dialogs for a selected wallet. /// @@ -13,31 +12,42 @@ import '../../l10n/app_localizations.dart'; class NWalletActions extends StatefulWidget { final NdkFlutter ndkFlutter; final String selectedWalletId; - final VoidCallback onClearSelection; + + /// Called when the close button is tapped. Required only when + /// [showCloseButton] is true. + final VoidCallback? onClearSelection; + + /// Whether to show the wallet-type header (icon + name) and the divider. + final bool showTitle; + + /// Whether to show the close (X) button in the header. + final bool showCloseButton; + + /// Condensed layout: drops the [Card] wrapper and tightens padding so the + /// widget is just the action buttons. Useful when embedding inline. + final bool condensed; const NWalletActions({ super.key, required this.ndkFlutter, required this.selectedWalletId, - required this.onClearSelection, - }); + this.onClearSelection, + this.showTitle = true, + this.showCloseButton = true, + this.condensed = false, + }) : assert( + !showCloseButton || onClearSelection != null, + 'onClearSelection is required when showCloseButton is true', + ); @override State createState() => _NWalletActionsState(); } -class _NWalletActionsState extends State { - void displayError(String error) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(error), backgroundColor: Colors.red)); - } - - void displaySuccess(String message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message), backgroundColor: Colors.green), - ); - } +class _NWalletActionsState extends State + with WalletActionDialogsMixin { + @override + NdkFlutter get ndkFlutter => widget.ndkFlutter; @override Widget build(BuildContext context) { @@ -54,19 +64,20 @@ class _NWalletActionsState extends State { final bool isCashu = wallet is CashuWallet; final bool isNwc = wallet is NwcWallet; - final bool isLnurl = wallet is LnurlWallet; final bool canSend = wallet.canSend; final bool canReceive = wallet.canReceive; - - return Card( - elevation: 4, - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ + final bool condensed = widget.condensed; + final bool showHeader = widget.showTitle || widget.showCloseButton; + final double buttonPadding = condensed ? 8 : 16; + + final content = Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (showHeader) ...[ + Row( + children: [ + if (widget.showTitle) ...[ if (isCashu) Image.asset( 'assets/images/cashu.png', @@ -101,672 +112,56 @@ class _NWalletActionsState extends State { : l10n.lnurlWallet, style: Theme.of(context).textTheme.titleMedium, ), - const Spacer(), + ], + const Spacer(), + if (widget.showCloseButton) IconButton( icon: const Icon(Icons.close), onPressed: widget.onClearSelection, ), - ], - ), - const Divider(), - const SizedBox(height: 8), - if (canSend || canReceive) - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - if (canSend) - Expanded( - child: ElevatedButton.icon( - onPressed: () => _showSendDialog(context, wallet), - icon: const Icon(Icons.send), - label: Text(l10n.send), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - ), - ), - ), - if (canSend && canReceive) const SizedBox(width: 16), - if (canReceive) - Expanded( - child: ElevatedButton.icon( - onPressed: () { - if (isNwc || isLnurl) { - _showCreateInvoiceDialog(context, wallet); - } else { - _showReceiveDialog(context, wallet); - } - }, - icon: const Icon(Icons.download), - label: Text(l10n.receive), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - ), - ), - ), - ], - ), - ], - ), - ), - ); - }, - ); - } - - void _showSendDialog(BuildContext context, Wallet wallet) { - final l10n = AppLocalizations.of(context)!; - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (context) { - return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - left: 16, - right: 16, - top: 16, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.sendOptionsTitle, - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 16), - if (wallet is CashuWallet) ...[ - ListTile( - leading: const Icon(Icons.receipt), - title: Text(l10n.sendByToken), - subtitle: Text(l10n.sendByTokenDescription), - onTap: () { - Navigator.pop(context); - _showSendTokenDialog(context, wallet); - }, - ), - ListTile( - leading: const Icon(Icons.flash_on), - title: Text(l10n.sendByLightning), - subtitle: Text(l10n.sendByLightningDescription), - onTap: () { - Navigator.pop(context); - _showPayInvoiceDialog(context, wallet); - }, - ), - ] else if (wallet is NwcWallet) ...[ - ListTile( - leading: const Icon(Icons.flash_on), - title: Text(l10n.payInvoiceTitle), - onTap: () { - Navigator.pop(context); - _showPayInvoiceDialog(context, wallet); - }, - ), - ], - const SizedBox(height: 16), - ], - ), - ); - }, - ); - } - - void _showReceiveDialog(BuildContext context, Wallet wallet) { - final l10n = AppLocalizations.of(context)!; - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (context) { - return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - left: 16, - right: 16, - top: 16, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.receiveOptionsTitle, - style: Theme.of(context).textTheme.headlineSmall, + ], ), - const SizedBox(height: 16), - if (wallet is CashuWallet) ...[ - ListTile( - leading: const Icon(Icons.receipt), - title: Text(l10n.receiveByToken), - subtitle: Text(l10n.receiveByTokenDescription), - onTap: () { - Navigator.pop(context); - _showReceiveTokenDialog(context, wallet); - }, - ), - ListTile( - leading: const Icon(Icons.flash_on), - title: Text(l10n.receiveByLightning), - subtitle: Text(l10n.receiveByLightningDescription), - onTap: () { - Navigator.pop(context); - _showCreateInvoiceDialog(context, wallet); - }, - ), - ] else if (wallet is NwcWallet) ...[ - ListTile( - leading: const Icon(Icons.flash_on), - title: Text(l10n.createInvoiceTitle), - onTap: () { - Navigator.pop(context); - _showCreateInvoiceDialog(context, wallet); - }, - ), - ], - const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 8), ], - ), - ); - }, - ); - } - - void _showSendTokenDialog(BuildContext context, CashuWallet wallet) { - final l10n = AppLocalizations.of(context)!; - final amountController = TextEditingController(); - final scaffoldMessenger = ScaffoldMessenger.of(context); - final navigator = Navigator.of(context); - showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: Text(l10n.sendByToken), - content: TextField( - controller: amountController, - keyboardType: TextInputType.number, - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: l10n.amount, - hintText: l10n.amountHint, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(l10n.cancel), - ), - TextButton( - onPressed: () async { - final amount = int.tryParse(amountController.text); - if (amount == null || amount <= 0) { - displayError(l10n.pleaseEnterValidAmount); - return; - } - - try { - final spendingResult = await widget.ndkFlutter.ndk.cashu - .initiateSpend( - mintUrl: wallet.mintUrl, - amount: amount, - unit: 'sat', - ); - final cashuString = spendingResult.token.toV4TokenString(); - - await Clipboard.setData(ClipboardData(text: cashuString)); - if (!mounted) return; - navigator.pop(); - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text(l10n.tokenCopiedToClipboard), - backgroundColor: Colors.green, - ), - ); - } catch (e) { - displayError(e.toString()); - } - }, - child: Text(l10n.createToken), - ), - ], - ); - }, - ); - } - - void _showPayInvoiceDialog(BuildContext context, Wallet wallet) { - final l10n = AppLocalizations.of(context)!; - final invoiceController = TextEditingController(); - final scaffoldMessenger = ScaffoldMessenger.of(context); - final navigator = Navigator.of(context); - showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: Text(l10n.payInvoiceTitle), - content: TextField( - controller: invoiceController, - maxLines: 3, - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: l10n.invoice, - hintText: l10n.invoiceHint, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(l10n.cancel), - ), - TextButton( - onPressed: () async { - final invoice = invoiceController.text.trim(); - if (invoice.isEmpty) { - displayError(l10n.pleaseEnterInvoice); - return; - } - - try { - if (wallet is CashuWallet) { - final draftTransaction = await widget.ndkFlutter.ndk.cashu - .initiateRedeem( - mintUrl: wallet.mintUrl, - request: invoice, - unit: 'sat', - method: 'bolt11', - ); - - await for (final transaction - in widget.ndkFlutter.ndk.cashu.redeem( - draftRedeemTransaction: draftTransaction, - )) { - if (transaction.state == - WalletTransactionState.completed) { - if (!mounted) return; - navigator.pop(); - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text(l10n.invoicePaid), - backgroundColor: Colors.green, - ), - ); - break; - } else if (transaction.state == - WalletTransactionState.failed) { - displayError( - l10n.paymentFailed(transaction.completionMsg ?? ''), - ); - break; - } - } - } else if (wallet is NwcWallet) { - final response = await widget.ndkFlutter.ndk.wallets.send( - walletId: wallet.id, - invoice: invoice, - ); - if (response.errorCode == null && - response.preimage != null) { - if (!mounted) return; - navigator.pop(); - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text(l10n.invoicePaid), - backgroundColor: Colors.green, - ), - ); - } else { - displayError( - l10n.paymentFailed( - response.errorMessage ?? l10n.unknownWalletType, + if (canSend || canReceive) + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + if (canSend) + Expanded( + child: ElevatedButton.icon( + onPressed: () => showSendDialog(context, wallet), + icon: const Icon(Icons.send), + label: Text(l10n.send), + style: ElevatedButton.styleFrom( + padding: EdgeInsets.symmetric(vertical: buttonPadding), ), - ); - } - } - } catch (e) { - displayError(e.toString()); - } - }, - child: Text(l10n.pay), - ), - ], - ); - }, - ); - } - - void _showReceiveTokenDialog(BuildContext context, CashuWallet wallet) { - final l10n = AppLocalizations.of(context)!; - final tokenController = TextEditingController(); - final scaffoldMessenger = ScaffoldMessenger.of(context); - final navigator = Navigator.of(context); - showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: Text(l10n.receiveByTokenTitle), - content: TextField( - controller: tokenController, - maxLines: 4, - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: l10n.token, - hintText: l10n.tokenHint, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(l10n.cancel), - ), - TextButton( - onPressed: () async { - final token = tokenController.text.trim(); - if (token.isEmpty) { - displayError(l10n.pleaseEnterToken); - return; - } - - try { - final rcvStream = widget.ndkFlutter.ndk.cashu.receive(token); - await rcvStream.last; - if (!mounted) return; - navigator.pop(); - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text(l10n.tokenReceived), - backgroundColor: Colors.green, - ), - ); - } catch (e) { - displayError(e.toString()); - } - }, - child: Text(l10n.receive), - ), - ], - ); - }, - ); - } - - void _showCreateInvoiceDialog(BuildContext context, Wallet wallet) { - final l10n = AppLocalizations.of(context)!; - final amountController = TextEditingController(); - final scaffoldMessenger = ScaffoldMessenger.of(context); - final navigator = Navigator.of(context); - showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: Text(l10n.createInvoiceTitle), - content: TextField( - controller: amountController, - keyboardType: TextInputType.number, - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: l10n.amount, - hintText: l10n.amountHint, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(l10n.cancel), - ), - TextButton( - onPressed: () async { - final amount = int.tryParse(amountController.text); - if (amount == null || amount <= 0) { - displayError(l10n.pleaseEnterValidAmount); - return; - } - - try { - if (wallet is CashuWallet) { - final draftTransaction = await widget.ndkFlutter.ndk.cashu - .initiateFund( - mintUrl: wallet.mintUrl, - amount: amount, - unit: 'sat', - method: 'bolt11', - ); - - if (draftTransaction.qoute?.request != null) { - final invoice = draftTransaction.qoute!.request; - await Clipboard.setData(ClipboardData(text: invoice)); - - if (!mounted) return; - navigator.pop(); - _showCashuInvoiceTrackingDialog( - invoice, - draftTransaction, - scaffoldMessenger, - ); - } - } else if (wallet is NwcWallet || wallet is LnurlWallet) { - final invoice = await widget.ndkFlutter.ndk.wallets.receive( - walletId: wallet.id, - amountSats: amount, - ); - await Clipboard.setData(ClipboardData(text: invoice)); - if (!mounted) return; - navigator.pop(); - _showGenericInvoiceTrackingDialog( - invoice, - scaffoldMessenger, - ); - } - } catch (e) { - displayError(e.toString()); - } - }, - child: Text(l10n.create), - ), - ], - ); - }, - ); - } - - void _showCashuInvoiceTrackingDialog( - String invoice, - CashuWalletTransaction draftTransaction, - ScaffoldMessengerState scaffoldMessenger, - ) { - final l10n = AppLocalizations.of(context)!; - final stream = widget.ndkFlutter.ndk.cashu.retrieveFunds( - draftTransaction: draftTransaction, - ); - - showDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) { - return AlertDialog( - title: Text(l10n.invoiceTrackingTitle), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(l10n.invoiceCreatedMessage), - const SizedBox(height: 12), - Container( - width: 200, - child: PrettyQrView.data( - data: invoice.toUpperCase(), - errorCorrectLevel: QrErrorCorrectLevel.M, - decoration: const PrettyQrDecoration( - quietZone: PrettyQrQuietZone.standart, - background: Colors.white, - shape: PrettyQrSmoothSymbol( - color: Colors.black, - roundFactor: 0.3, + ), ), - ), - ), - ), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.grey[200], - borderRadius: BorderRadius.circular(8), - ), - child: SelectableText( - invoice, - style: const TextStyle(fontSize: 11, fontFamily: 'monospace'), - ), - ), - const SizedBox(height: 16), - StreamBuilder( - stream: stream, - builder: (context, snapshot) { - if (snapshot.hasData) { - final tx = snapshot.data!; - if (tx.state == WalletTransactionState.completed) { - WidgetsBinding.instance.addPostFrameCallback((_) { - Navigator.of(dialogContext).pop(); - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text(l10n.paymentReceived), - backgroundColor: Colors.green, - ), - ); - }); - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.check_circle, color: Colors.green), - const SizedBox(width: 8), - Text( - l10n.paid, - style: const TextStyle(color: Colors.green), - ), - ], - ); - } else if (tx.state == WalletTransactionState.failed) { - return Text( - l10n.paymentFailed(tx.completionMsg ?? ''), - style: const TextStyle(color: Colors.red), - ); - } - } - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), + if (canSend && canReceive) + SizedBox(width: condensed ? 8 : 16), + if (canReceive) + Expanded( + child: ElevatedButton.icon( + onPressed: () => showReceiveFlow(context, wallet), + icon: const Icon(Icons.download), + label: Text(l10n.receive), + style: ElevatedButton.styleFrom( + padding: EdgeInsets.symmetric(vertical: buttonPadding), + ), ), - const SizedBox(width: 8), - Text(l10n.waitingForPayment), - ], - ); - }, + ), + ], ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: Text(l10n.close), - ), - TextButton( - onPressed: () { - Clipboard.setData(ClipboardData(text: invoice)); - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text(l10n.copied), - backgroundColor: Colors.green, - ), - ); - }, - child: Text(l10n.copyAgain), - ), ], ); - }, - ); - } - - void _showGenericInvoiceTrackingDialog( - String invoice, - ScaffoldMessengerState scaffoldMessenger, - ) { - final l10n = AppLocalizations.of(context)!; - showDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) { - return AlertDialog( - title: Text(l10n.invoiceTrackingTitle), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(l10n.invoiceCreatedMessage), - const SizedBox(height: 12), - Container( - width: 200, - child: PrettyQrView.data( - data: invoice.toUpperCase(), - errorCorrectLevel: QrErrorCorrectLevel.M, - decoration: const PrettyQrDecoration( - quietZone: PrettyQrQuietZone.standart, - background: Colors.white, - shape: PrettyQrSmoothSymbol( - color: Colors.black, - roundFactor: 0.3, - ), - ), - ), - ), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.grey[200], - borderRadius: BorderRadius.circular(8), - ), - child: SelectableText( - invoice, - style: const TextStyle(fontSize: 11, fontFamily: 'monospace'), - ), - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.info_outline, color: Colors.blue), - const SizedBox(width: 8), - Flexible( - child: Text( - l10n.waitingForPayment, - style: const TextStyle(color: Colors.blue), - ), - ), - ], - ), - ], - ), + if (condensed) return content; - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: Text(l10n.close), - ), - TextButton( - onPressed: () { - Clipboard.setData(ClipboardData(text: invoice)); - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text(l10n.copied), - backgroundColor: Colors.green, - ), - ); - }, - child: Text(l10n.copyAgain), - ), - ], + return Card( + elevation: 4, + child: Padding(padding: const EdgeInsets.all(20), child: content), ); }, ); diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card.dart b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card.dart index 6fceaea09..647abe059 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card.dart @@ -5,6 +5,7 @@ import 'package:ndk/ndk.dart'; import 'package:ndk_flutter/ndk_flutter.dart'; import '../../l10n/app_localizations.dart'; +import 'wallet_action_dialogs.dart'; /// Configuration for wallet type icons class WalletIconConfig { @@ -78,11 +79,15 @@ class NWalletCard extends StatefulWidget { State createState() => _NWalletCardState(); } -class _NWalletCardState extends State { +class _NWalletCardState extends State + with WalletActionDialogsMixin { List? _customGradientColors; GetBudgetResponse? _budgetResponse; bool _isFetchingBudget = false; + @override + NdkFlutter get ndkFlutter => widget.ndkFlutter; + Set _nwcPermissions(NwcWallet wallet) { final livePermissions = wallet.connection?.permissions; if (livePermissions != null && livePermissions.isNotEmpty) { @@ -488,6 +493,76 @@ class _NWalletCardState extends State { child: PopupMenuButton( icon: const Icon(Icons.more_vert, color: Colors.white70), itemBuilder: (context) { + final bool isCashuWallet = widget.wallet is CashuWallet; + final reclaimable = isCashuWallet + ? reclaimablePending( + widget.ndkFlutter.ndk.cashu.pendingTransactions + .valueOrNull ?? + const [], + mintUrl: (widget.wallet as CashuWallet).mintUrl, + ) + : const []; + + // Send / Receive / Reclaim section, enabled per capability. + final actionItems = >[ + PopupMenuItem( + value: 'send', + enabled: widget.wallet.canSend, + child: Row( + children: [ + const Icon(Icons.send, size: 20), + const SizedBox(width: 8), + Text(l10n.send), + ], + ), + ), + PopupMenuItem( + value: 'receive', + enabled: widget.wallet.canReceive, + child: Row( + children: [ + const Icon(Icons.download, size: 20), + const SizedBox(width: 8), + Text(l10n.receive), + ], + ), + ), + if (isCashuWallet) + PopupMenuItem( + value: 'reclaim', + enabled: reclaimable.isNotEmpty, + child: Row( + children: [ + const Icon(Icons.replay, size: 20), + const SizedBox(width: 8), + Text(l10n.reclaimPendingFunds), + ], + ), + ), + if (isCashuWallet) + PopupMenuItem( + value: 'backup', + child: Row( + children: [ + const Icon(Icons.backup, size: 20), + const SizedBox(width: 8), + Text(l10n.backup), + ], + ), + ), + if (isCashuWallet) + PopupMenuItem( + value: 'restore', + child: Row( + children: [ + const Icon(Icons.settings_backup_restore, size: 20), + const SizedBox(width: 8), + Text(l10n.restore), + ], + ), + ), + ]; + final defaultItems = >[ if (widget.wallet.canReceive) PopupMenuItem( @@ -540,6 +615,8 @@ class _NWalletCardState extends State { ]; return [ + ...actionItems, + const PopupMenuDivider(), ...defaultItems, if (defaultItems.isNotEmpty) const PopupMenuDivider(), PopupMenuItem( @@ -586,6 +663,30 @@ class _NWalletCardState extends State { }, onSelected: (value) async { switch (value) { + case 'send': + showSendDialog(context, widget.wallet); + break; + case 'receive': + showReceiveFlow(context, widget.wallet); + break; + case 'reclaim': + await showReclaimPending( + context, + widget.wallet as CashuWallet, + ); + break; + case 'backup': + showBackupDialog( + context, + widget.wallet as CashuWallet, + ); + break; + case 'restore': + showRestoreDialog( + context, + widget.wallet as CashuWallet, + ); + break; case 'set_default_receive': widget.ndkFlutter.ndk.wallets .setDefaultWalletForReceiving(widget.wallet.id); diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_wallets.dart b/packages/ndk_flutter/lib/widgets/wallets/n_wallets.dart index d1c295b3b..76a0141f1 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_wallets.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_wallets.dart @@ -157,6 +157,7 @@ class NWalletsState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ widget.header ?? _buildHeader(), + NCashuSeedBackupWarning(ndkFlutter: widget.ndkFlutter), const SizedBox(height: 16), Expanded( child: NWalletCardList( @@ -190,6 +191,7 @@ class NWalletsState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ widget.header ?? _buildHeader(), + NCashuSeedBackupWarning(ndkFlutter: widget.ndkFlutter), const SizedBox(height: 16), SizedBox( height: widget.walletCardsHeight, diff --git a/packages/ndk_flutter/lib/widgets/wallets/wallet_action_dialogs.dart b/packages/ndk_flutter/lib/widgets/wallets/wallet_action_dialogs.dart new file mode 100644 index 000000000..205594025 --- /dev/null +++ b/packages/ndk_flutter/lib/widgets/wallets/wallet_action_dialogs.dart @@ -0,0 +1,995 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:ndk/entities.dart'; +import 'package:ndk_flutter/ndk_flutter.dart'; +import 'package:pretty_qr_code/pretty_qr_code.dart'; + +import '../../l10n/app_localizations.dart'; + +/// Funding transactions reclaimable via [Cashu.retrieveFunds]: they carry a +/// mint quote, method and used keysets. Pending sends/redeems have none and are +/// skipped. Optionally filtered to a single [mintUrl]. +List reclaimablePending( + Iterable transactions, { + String? mintUrl, +}) { + return transactions + .whereType() + .where( + (tx) => + (mintUrl == null || tx.mintUrl == mintUrl) && + tx.qoute != null && + tx.method != null && + tx.usedKeysets != null, + ) + .toList(); +} + +/// Runs [Cashu.retrieveFunds] for each [reclaimable] funding transaction and +/// shows live per-transaction status in a dialog. +Future showReclaimDialog( + BuildContext context, + NdkFlutter ndkFlutter, + List reclaimable, +) async { + final l10n = AppLocalizations.of(context)!; + + // Start the reclaim stream for each transaction exactly once so the + // StreamBuilder tiles don't restart the process on every rebuild. + final streams = >{ + for (final tx in reclaimable) + tx: ndkFlutter.ndk.cashu + .retrieveFunds(draftTransaction: tx) + .asBroadcastStream(), + }; + + await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(l10n.reclaimPendingTitle), + content: SizedBox( + width: double.maxFinite, + child: ListView( + shrinkWrap: true, + children: [ + for (final tx in reclaimable) + ReclaimPendingTile(transaction: tx, stream: streams[tx]!), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(l10n.close), + ), + ], + ); + }, + ); +} + +/// Single row in the reclaim-pending dialog showing the live state of one +/// [Cashu.retrieveFunds] stream. +class ReclaimPendingTile extends StatelessWidget { + final CashuWalletTransaction transaction; + final Stream stream; + + const ReclaimPendingTile({ + super.key, + required this.transaction, + required this.stream, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return StreamBuilder( + stream: stream, + builder: (context, snapshot) { + final state = snapshot.data?.state ?? transaction.state; + final bool errored = + snapshot.hasError || state == WalletTransactionState.failed; + final bool completed = state == WalletTransactionState.completed; + + // Reason for the red mark: stream exception, or the failed tx message. + final String? reason = snapshot.hasError + ? snapshot.error.toString() + : (state == WalletTransactionState.failed + ? snapshot.data?.completionMsg + : null); + + final Widget trailing; + if (errored) { + trailing = Tooltip( + message: reason ?? state.value, + triggerMode: TooltipTriggerMode.tap, + child: const Icon(Icons.error, color: Colors.red, size: 20), + ); + } else if (completed) { + trailing = const Icon( + Icons.check_circle, + color: Colors.green, + size: 20, + ); + } else { + trailing = const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ); + } + + // While still pending the loop polls the mint until the invoice is + // paid; make that explicit instead of showing a bare spinner. + final String subtitle; + if (errored && reason != null) { + subtitle = reason; + } else if (completed) { + subtitle = state.value; + } else { + subtitle = l10n.waitingForPayment; + } + + return ListTile( + dense: true, + leading: const Icon(Icons.download), + title: Text('${transaction.changeAmount.abs()} ${transaction.unit}'), + subtitle: Text( + subtitle, + style: errored ? const TextStyle(color: Colors.red) : null, + ), + trailing: trailing, + ); + }, + ); + } +} + +/// Send/Receive/Reclaim wallet operation flows shared by the wallet actions +/// panel and the wallet card menu. Mix into any [State] that exposes an +/// [ndkFlutter] instance. +mixin WalletActionDialogsMixin on State { + /// The NDK instance used to perform wallet operations. + NdkFlutter get ndkFlutter; + + void displayError(String error) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error), backgroundColor: Colors.red)); + } + + void displaySuccess(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), backgroundColor: Colors.green), + ); + } + + /// Receive flow that picks the right dialog per wallet type. + void showReceiveFlow(BuildContext context, Wallet wallet) { + if (wallet is NwcWallet || wallet is LnurlWallet) { + _showCreateInvoiceDialog(context, wallet); + } else { + _showReceiveDialog(context, wallet); + } + } + + /// Reclaims all reclaimable pending funding transactions of [wallet]. + Future showReclaimPending( + BuildContext context, + CashuWallet wallet, + ) async { + final reclaimable = reclaimablePending( + ndkFlutter.ndk.cashu.pendingTransactions.valueOrNull ?? + const [], + mintUrl: wallet.mintUrl, + ); + await showReclaimDialog(context, ndkFlutter, reclaimable); + } + + /// Shows the cashu backup dialog: generates a JSON backup of the local cashu + /// database (proofs, keysets, counters, transactions) and lets the user copy + /// it. The seed phrase is global and backed up separately, so it is not + /// included here. Proofs are bearer funds, hence the warning. + void showBackupDialog(BuildContext context, CashuWallet wallet) { + final l10n = AppLocalizations.of(context)!; + String? backupJson; + bool generating = false; + + showDialog( + context: context, + builder: (dialogContext) { + return StatefulBuilder( + builder: (context, setDialogState) { + Future generate() async { + setDialogState(() => generating = true); + try { + final json = await ndkFlutter.ndk.cashu + .exportCashuStateJsonString(); + setDialogState(() { + backupJson = json; + generating = false; + }); + } catch (e) { + setDialogState(() => generating = false); + displayError(e.toString()); + } + } + + return AlertDialog( + title: Text(l10n.cashuBackupTitle), + content: SizedBox( + width: double.maxFinite, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon( + Icons.warning_amber, + color: Colors.orange, + size: 20, + ), + const SizedBox(width: 8), + Expanded(child: Text(l10n.cashuBackupWarning)), + ], + ), + ), + const SizedBox(height: 8), + if (backupJson != null) + Flexible( + child: SingleChildScrollView( + child: Container( + width: double.maxFinite, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(8), + ), + child: SelectableText( + backupJson!, + style: const TextStyle( + fontSize: 11, + fontFamily: 'monospace', + ), + ), + ), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(l10n.close), + ), + if (backupJson == null) + TextButton( + onPressed: generating ? null : generate, + child: Text( + generating ? l10n.generatingBackup : l10n.backup, + ), + ) + else + TextButton( + onPressed: () async { + await Clipboard.setData(ClipboardData(text: backupJson!)); + displaySuccess(l10n.backupCopiedToClipboard); + }, + child: Text(l10n.copyBackup), + ), + ], + ); + }, + ); + }, + ); + } + + /// Shows the cashu restore dialog: the user pastes a backup JSON and it is + /// imported into local storage. The seed phrase is managed separately and is + /// not part of this backup. + void showRestoreDialog(BuildContext context, CashuWallet wallet) { + final l10n = AppLocalizations.of(context)!; + final controller = TextEditingController(); + final scaffoldMessenger = ScaffoldMessenger.of(context); + bool restoring = false; + + showDialog( + context: context, + builder: (dialogContext) { + return StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + title: Text(l10n.cashuRestoreTitle), + content: TextField( + controller: controller, + maxLines: 6, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.backupJson, + hintText: l10n.backupJsonHint, + ), + ), + actions: [ + TextButton( + onPressed: restoring + ? null + : () => Navigator.of(dialogContext).pop(), + child: Text(l10n.cancel), + ), + TextButton( + onPressed: restoring + ? null + : () async { + final json = controller.text.trim(); + if (json.isEmpty) { + displayError(l10n.pleaseEnterBackup); + return; + } + setDialogState(() => restoring = true); + try { + final result = await ndkFlutter.ndk.cashu + .importCashuStateJsonString(json); + + // Close via the dialog's own navigator and report + // through the captured messenger so teardown does + // not depend on this card's State staying mounted + // (restoring refreshes balances, which can rebuild + // and dispose this widget). + if (dialogContext.mounted) { + Navigator.of(dialogContext).pop(); + } + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + l10n.restoreSuccess(result.restoredProofs), + ), + backgroundColor: Colors.green, + ), + ); + } catch (e) { + setDialogState(() => restoring = false); + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text(e.toString()), + backgroundColor: Colors.red, + ), + ); + } + }, + child: Text(restoring ? l10n.restoringBackup : l10n.restore), + ), + ], + ); + }, + ); + }, + ); + } + + void showSendDialog(BuildContext context, Wallet wallet) { + final l10n = AppLocalizations.of(context)!; + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + left: 16, + right: 16, + top: 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.sendOptionsTitle, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 16), + if (wallet is CashuWallet) ...[ + ListTile( + leading: const Icon(Icons.receipt), + title: Text(l10n.sendByToken), + subtitle: Text(l10n.sendByTokenDescription), + onTap: () { + Navigator.pop(context); + _showSendTokenDialog(context, wallet); + }, + ), + ListTile( + leading: const Icon(Icons.flash_on), + title: Text(l10n.sendByLightning), + subtitle: Text(l10n.sendByLightningDescription), + onTap: () { + Navigator.pop(context); + _showPayInvoiceDialog(context, wallet); + }, + ), + ] else if (wallet is NwcWallet) ...[ + ListTile( + leading: const Icon(Icons.flash_on), + title: Text(l10n.payInvoiceTitle), + onTap: () { + Navigator.pop(context); + _showPayInvoiceDialog(context, wallet); + }, + ), + ], + const SizedBox(height: 16), + ], + ), + ); + }, + ); + } + + void _showReceiveDialog(BuildContext context, Wallet wallet) { + final l10n = AppLocalizations.of(context)!; + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + left: 16, + right: 16, + top: 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.receiveOptionsTitle, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 16), + if (wallet is CashuWallet) ...[ + ListTile( + leading: const Icon(Icons.receipt), + title: Text(l10n.receiveByToken), + subtitle: Text(l10n.receiveByTokenDescription), + onTap: () { + Navigator.pop(context); + _showReceiveTokenDialog(context, wallet); + }, + ), + ListTile( + leading: const Icon(Icons.flash_on), + title: Text(l10n.receiveByLightning), + subtitle: Text(l10n.receiveByLightningDescription), + onTap: () { + Navigator.pop(context); + _showCreateInvoiceDialog(context, wallet); + }, + ), + ] else if (wallet is NwcWallet) ...[ + ListTile( + leading: const Icon(Icons.flash_on), + title: Text(l10n.createInvoiceTitle), + onTap: () { + Navigator.pop(context); + _showCreateInvoiceDialog(context, wallet); + }, + ), + ], + const SizedBox(height: 16), + ], + ), + ); + }, + ); + } + + void _showSendTokenDialog(BuildContext context, CashuWallet wallet) { + final l10n = AppLocalizations.of(context)!; + final amountController = TextEditingController(); + final scaffoldMessenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context); + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(l10n.sendByToken), + content: TextField( + controller: amountController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.amount, + hintText: l10n.amountHint, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.cancel), + ), + TextButton( + onPressed: () async { + final amount = int.tryParse(amountController.text); + if (amount == null || amount <= 0) { + displayError(l10n.pleaseEnterValidAmount); + return; + } + + try { + final spendingResult = await ndkFlutter.ndk.cashu + .initiateSpend( + mintUrl: wallet.mintUrl, + amount: amount, + unit: 'sat', + ); + final cashuString = spendingResult.token.toV4TokenString(); + + await Clipboard.setData(ClipboardData(text: cashuString)); + if (!mounted) return; + navigator.pop(); + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text(l10n.tokenCopiedToClipboard), + backgroundColor: Colors.green, + ), + ); + } catch (e) { + displayError(e.toString()); + } + }, + child: Text(l10n.createToken), + ), + ], + ); + }, + ); + } + + void _showPayInvoiceDialog(BuildContext context, Wallet wallet) { + final l10n = AppLocalizations.of(context)!; + final invoiceController = TextEditingController(); + final scaffoldMessenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context); + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(l10n.payInvoiceTitle), + content: TextField( + controller: invoiceController, + maxLines: 3, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.invoice, + hintText: l10n.invoiceHint, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.cancel), + ), + TextButton( + onPressed: () async { + final invoice = invoiceController.text.trim(); + if (invoice.isEmpty) { + displayError(l10n.pleaseEnterInvoice); + return; + } + + try { + if (wallet is CashuWallet) { + final draftTransaction = await ndkFlutter.ndk.cashu + .initiateRedeem( + mintUrl: wallet.mintUrl, + request: invoice, + unit: 'sat', + method: 'bolt11', + ); + + await for (final transaction in ndkFlutter.ndk.cashu.redeem( + draftRedeemTransaction: draftTransaction, + )) { + if (transaction.state == + WalletTransactionState.completed) { + if (!mounted) return; + navigator.pop(); + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text(l10n.invoicePaid), + backgroundColor: Colors.green, + ), + ); + break; + } else if (transaction.state == + WalletTransactionState.failed) { + displayError( + l10n.paymentFailed(transaction.completionMsg ?? ''), + ); + break; + } + } + } else if (wallet is NwcWallet) { + final response = await ndkFlutter.ndk.wallets.send( + walletId: wallet.id, + invoice: invoice, + ); + if (response.errorCode == null && + response.preimage != null) { + if (!mounted) return; + navigator.pop(); + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text(l10n.invoicePaid), + backgroundColor: Colors.green, + ), + ); + } else { + displayError( + l10n.paymentFailed( + response.errorMessage ?? l10n.unknownWalletType, + ), + ); + } + } + } catch (e) { + displayError(e.toString()); + } + }, + child: Text(l10n.pay), + ), + ], + ); + }, + ); + } + + void _showReceiveTokenDialog(BuildContext context, CashuWallet wallet) { + final l10n = AppLocalizations.of(context)!; + final tokenController = TextEditingController(); + final scaffoldMessenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context); + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(l10n.receiveByTokenTitle), + content: TextField( + controller: tokenController, + maxLines: 4, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.token, + hintText: l10n.tokenHint, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.cancel), + ), + TextButton( + onPressed: () async { + final token = tokenController.text.trim(); + if (token.isEmpty) { + displayError(l10n.pleaseEnterToken); + return; + } + + try { + final rcvStream = ndkFlutter.ndk.cashu.receive(token); + await rcvStream.last; + if (!mounted) return; + navigator.pop(); + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text(l10n.tokenReceived), + backgroundColor: Colors.green, + ), + ); + } catch (e) { + displayError(e.toString()); + } + }, + child: Text(l10n.receive), + ), + ], + ); + }, + ); + } + + void _showCreateInvoiceDialog(BuildContext context, Wallet wallet) { + final l10n = AppLocalizations.of(context)!; + final amountController = TextEditingController(); + final scaffoldMessenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context); + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(l10n.createInvoiceTitle), + content: TextField( + controller: amountController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.amount, + hintText: l10n.amountHint, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.cancel), + ), + TextButton( + onPressed: () async { + final amount = int.tryParse(amountController.text); + if (amount == null || amount <= 0) { + displayError(l10n.pleaseEnterValidAmount); + return; + } + + try { + if (wallet is CashuWallet) { + final draftTransaction = await ndkFlutter.ndk.cashu + .initiateFund( + mintUrl: wallet.mintUrl, + amount: amount, + unit: 'sat', + method: 'bolt11', + ); + + if (draftTransaction.qoute?.request != null) { + final invoice = draftTransaction.qoute!.request; + await Clipboard.setData(ClipboardData(text: invoice)); + + if (!mounted) return; + navigator.pop(); + _showCashuInvoiceTrackingDialog( + invoice, + draftTransaction, + scaffoldMessenger, + ); + } + } else if (wallet is NwcWallet || wallet is LnurlWallet) { + final invoice = await ndkFlutter.ndk.wallets.receive( + walletId: wallet.id, + amountSats: amount, + ); + await Clipboard.setData(ClipboardData(text: invoice)); + if (!mounted) return; + navigator.pop(); + _showGenericInvoiceTrackingDialog( + invoice, + scaffoldMessenger, + ); + } + } catch (e) { + displayError(e.toString()); + } + }, + child: Text(l10n.create), + ), + ], + ); + }, + ); + } + + void _showCashuInvoiceTrackingDialog( + String invoice, + CashuWalletTransaction draftTransaction, + ScaffoldMessengerState scaffoldMessenger, + ) { + final l10n = AppLocalizations.of(context)!; + final stream = ndkFlutter.ndk.cashu.retrieveFunds( + draftTransaction: draftTransaction, + ); + + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return AlertDialog( + title: Text(l10n.invoiceTrackingTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.invoiceCreatedMessage), + const SizedBox(height: 12), + SizedBox( + width: 200, + child: PrettyQrView.data( + data: invoice.toUpperCase(), + errorCorrectLevel: QrErrorCorrectLevel.M, + decoration: const PrettyQrDecoration( + quietZone: PrettyQrQuietZone.standart, + background: Colors.white, + shape: PrettyQrSmoothSymbol( + color: Colors.black, + roundFactor: 0.3, + ), + ), + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(8), + ), + child: SelectableText( + invoice, + style: const TextStyle(fontSize: 11, fontFamily: 'monospace'), + ), + ), + const SizedBox(height: 16), + StreamBuilder( + stream: stream, + builder: (context, snapshot) { + if (snapshot.hasData) { + final tx = snapshot.data!; + if (tx.state == WalletTransactionState.completed) { + WidgetsBinding.instance.addPostFrameCallback((_) { + Navigator.of(dialogContext).pop(); + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text(l10n.paymentReceived), + backgroundColor: Colors.green, + ), + ); + }); + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.check_circle, color: Colors.green), + const SizedBox(width: 8), + Text( + l10n.paid, + style: const TextStyle(color: Colors.green), + ), + ], + ); + } else if (tx.state == WalletTransactionState.failed) { + return Text( + l10n.paymentFailed(tx.completionMsg ?? ''), + style: const TextStyle(color: Colors.red), + ); + } + } + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 8), + Text(l10n.waitingForPayment), + ], + ); + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(l10n.close), + ), + TextButton( + onPressed: () { + Clipboard.setData(ClipboardData(text: invoice)); + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text(l10n.copied), + backgroundColor: Colors.green, + ), + ); + }, + child: Text(l10n.copyAgain), + ), + ], + ); + }, + ); + } + + void _showGenericInvoiceTrackingDialog( + String invoice, + ScaffoldMessengerState scaffoldMessenger, + ) { + final l10n = AppLocalizations.of(context)!; + + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return AlertDialog( + title: Text(l10n.invoiceTrackingTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.invoiceCreatedMessage), + const SizedBox(height: 12), + SizedBox( + width: 200, + child: PrettyQrView.data( + data: invoice.toUpperCase(), + errorCorrectLevel: QrErrorCorrectLevel.M, + decoration: const PrettyQrDecoration( + quietZone: PrettyQrQuietZone.standart, + background: Colors.white, + shape: PrettyQrSmoothSymbol( + color: Colors.black, + roundFactor: 0.3, + ), + ), + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(8), + ), + child: SelectableText( + invoice, + style: const TextStyle(fontSize: 11, fontFamily: 'monospace'), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.info_outline, color: Colors.blue), + const SizedBox(width: 8), + Flexible( + child: Text( + l10n.waitingForPayment, + style: const TextStyle(color: Colors.blue), + ), + ), + ], + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(l10n.close), + ), + TextButton( + onPressed: () { + Clipboard.setData(ClipboardData(text: invoice)); + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text(l10n.copied), + backgroundColor: Colors.green, + ), + ); + }, + child: Text(l10n.copyAgain), + ), + ], + ); + }, + ); + } +} diff --git a/packages/ndk_flutter/lib/widgets/widgets.dart b/packages/ndk_flutter/lib/widgets/widgets.dart index 4ae3aead2..705d9a72b 100644 --- a/packages/ndk_flutter/lib/widgets/widgets.dart +++ b/packages/ndk_flutter/lib/widgets/widgets.dart @@ -8,6 +8,7 @@ export 'pending_requests/n_pending_requests.dart'; export 'locale_switcher/n_locale_switcher.dart'; export 'wallets/n_wallets.dart'; export 'wallets/n_wallet_card.dart'; +export 'wallets/n_cashu_seed_backup_warning.dart'; export 'wallets/n_wallet_card_list.dart'; export 'wallets/n_pending_transactions.dart'; export 'wallets/n_recent_transactions.dart'; diff --git a/packages/sample-app/android/app/build.gradle b/packages/sample-app/android/app/build.gradle index f55417ea9..a0cd3ef9f 100644 --- a/packages/sample-app/android/app/build.gradle +++ b/packages/sample-app/android/app/build.gradle @@ -33,8 +33,8 @@ if (flutterVersionName == null) { android { namespace "com.example.example" - compileSdkVersion 35 - ndkVersion "27.0.12077973" + compileSdkVersion 36 + ndkVersion "28.2.13676358" compileOptions { sourceCompatibility JavaVersion.VERSION_17 diff --git a/packages/sample-app/android/build.gradle b/packages/sample-app/android/build.gradle index 8b7b3622e..f1c26cf8f 100644 --- a/packages/sample-app/android/build.gradle +++ b/packages/sample-app/android/build.gradle @@ -1,5 +1,5 @@ buildscript { - ext.kotlin_version = '2.1.0' + ext.kotlin_version = '2.3.21' repositories { google() mavenCentral() @@ -21,7 +21,7 @@ subprojects { afterEvaluate { project -> if (project.hasProperty('android')) { project.android { - compileSdkVersion 35 + compileSdkVersion 36 } } } diff --git a/packages/sample-app/android/gradle.properties b/packages/sample-app/android/gradle.properties index 598d13fee..f2122b21a 100644 --- a/packages/sample-app/android/gradle.properties +++ b/packages/sample-app/android/gradle.properties @@ -1,3 +1,7 @@ org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/packages/sample-app/android/gradle/wrapper/gradle-wrapper.properties b/packages/sample-app/android/gradle/wrapper/gradle-wrapper.properties index afa1e8eb0..efdcc4ace 100644 --- a/packages/sample-app/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/sample-app/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip diff --git a/packages/sample-app/android/settings.gradle b/packages/sample-app/android/settings.gradle index 220471060..f02957c6c 100644 --- a/packages/sample-app/android/settings.gradle +++ b/packages/sample-app/android/settings.gradle @@ -23,8 +23,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.7.1" apply false - id "org.jetbrains.kotlin.android" version "2.1.0" apply false + id "com.android.application" version "8.9.1" apply false + id "org.jetbrains.kotlin.android" version "2.3.21" apply false } include ":app" diff --git a/packages/sample-app/lib/demo_app_config.dart b/packages/sample-app/lib/demo_app_config.dart index 6e9463635..5bbc88706 100644 --- a/packages/sample-app/lib/demo_app_config.dart +++ b/packages/sample-app/lib/demo_app_config.dart @@ -1,7 +1,3 @@ class DemoAppConfig { static const String appName = 'Nostr Developer Kit Demo'; - - /// in production store the user seed phrase securely! (e.g. in secure storage) - static const String cashuSeedPhrase = - "slender horror knee exclude couch oil picture tone steel dinosaur arrow culture"; } diff --git a/packages/sample-app/lib/main.dart b/packages/sample-app/lib/main.dart index f99d3c575..66b794982 100644 --- a/packages/sample-app/lib/main.dart +++ b/packages/sample-app/lib/main.dart @@ -6,7 +6,6 @@ import 'package:media_kit/media_kit.dart'; import 'package:ndk/entities.dart'; import 'package:ndk/ndk.dart'; import 'package:ndk_demo/l10n/app_localizations_context.dart'; -import 'package:ndk_demo/l10n/generated/sample_app_localizations.dart'; import 'package:ndk_demo/router.dart'; import 'package:ndk_drift/ndk_drift.dart'; import 'package:ndk_flutter/l10n/app_localizations.dart' as ndk_flutter; @@ -14,7 +13,8 @@ import 'package:ndk_flutter/ndk_flutter.dart'; import 'package:path_provider/path_provider.dart'; import 'package:protocol_handler/protocol_handler.dart'; -import 'demo_app_config.dart'; +import 'l10n/generated/sample_app_localizations.dart'; + bool amberAvailable = false; @@ -43,6 +43,10 @@ Future main() async { : await SembastCacheManager.create( databasePath: (await getApplicationDocumentsDirectory()).path); + // Load the cashu seed phrase from secure storage, generating a fresh one on + // first run. Never hardcode this — it controls cashu funds. + final cashuSeedPhrase = await CashuSeedStore().loadOrCreate(); + final eventVerifier = kIsWeb ? WebEventVerifier() : RustEventVerifier(); ndk = Ndk( NdkConfig( @@ -51,7 +55,7 @@ Future main() async { walletsRepo: FlutterSecureStorageWalletsRepo(), logLevel: Logger.logLevels.info, cashuUserSeedphrase: CashuUserSeedphrase( - seedPhrase: DemoAppConfig.cashuSeedPhrase, + seedPhrase: cashuSeedPhrase, ), ), ); @@ -121,7 +125,10 @@ class _MyAppState extends State with ProtocolListener { GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], - supportedLocales: SampleAppLocalizations.supportedLocales, + // Use ndk_flutter's locale list — it's the superset that includes the + // extra locales (fi, pt, pt_BR) translated in that package. The sample + // app shell falls back to English for those via the delegate above. + supportedLocales: ndk_flutter.AppLocalizations.supportedLocales, routerConfig: appRouter, builder: (context, child) => Stack( children: [ diff --git a/packages/sample-app/pubspec.lock b/packages/sample-app/pubspec.lock index 145170021..6a7eeb6c9 100644 --- a/packages/sample-app/pubspec.lock +++ b/packages/sample-app/pubspec.lock @@ -587,7 +587,7 @@ packages: path: "../ndk" relative: true source: path - version: "0.8.4-dev.1" + version: "0.8.4-dev.2" ndk_bip32_keys: dependency: transitive description: @@ -602,14 +602,14 @@ packages: path: "../drift" relative: true source: path - version: "0.1.1-dev.1" + version: "0.1.1-dev.3" ndk_flutter: dependency: "direct main" description: path: "../ndk_flutter" relative: true source: path - version: "0.8.4-dev.1" + version: "0.8.4-dev.3" nested: dependency: transitive description: @@ -624,7 +624,7 @@ packages: path: "../nip07_event_signer" relative: true source: path - version: "1.1.0-dev.1" + version: "1.1.0-dev.2" objective_c: dependency: transitive description: