From d489697f6d5bf15ccdb7283e15681a8f1813c3c1 Mon Sep 17 00:00:00 2001 From: fmar Date: Mon, 17 Aug 2026 17:21:51 +0200 Subject: [PATCH 01/22] bip321 support --- packages/ndk/example/nwc/README.md | 16 ++ .../ndk/example/nwc/connect_get_info.dart | 1 + packages/ndk/example/nwc/pay.dart | 39 ++++ packages/ndk/example/nwc/receive.dart | 29 +++ .../domain_layer/entities/wallet/bip321.dart | 86 ++++++++ .../cashu/cashu_wallet_provider.dart | 147 +++++++++++++- .../lnurl/lnurl_wallet_provider.dart | 54 +++++ .../wallet/providers/nwc/nwc_wallet.dart | 6 +- .../providers/nwc/nwc_wallet_provider.dart | 54 +++++ .../entities/wallet/wallet_provider.dart | 21 ++ .../usecases/nwc/consts/error_code.dart | 9 + .../usecases/nwc/consts/nwc_method.dart | 4 + .../lib/domain_layer/usecases/nwc/nwc.dart | 46 +++++ .../usecases/nwc/requests/pay.dart | 38 ++++ .../usecases/nwc/requests/receive.dart | 30 +++ .../usecases/nwc/responses/pay_response.dart | 83 ++++++++ .../nwc/responses/receive_response.dart | 30 +++ .../usecases/wallets/wallets.dart | 58 ++++++ packages/ndk/lib/entities.dart | 1 + packages/ndk/lib/ndk.dart | 3 + packages/ndk/test/entities/bip321_test.dart | 46 +++++ .../ndk/test/entities/nwc_wallet_test.dart | 19 ++ .../ndk/test/usecases/nwc/nwc_321_test.dart | 162 +++++++++++++++ .../test/usecases/nwc/nwc_method_test.dart | 2 + .../test/usecases/wallets_bip321_test.dart | 189 ++++++++++++++++++ 25 files changed, 1168 insertions(+), 5 deletions(-) create mode 100644 packages/ndk/example/nwc/pay.dart create mode 100644 packages/ndk/example/nwc/receive.dart create mode 100644 packages/ndk/lib/domain_layer/entities/wallet/bip321.dart create mode 100644 packages/ndk/lib/domain_layer/usecases/nwc/requests/pay.dart create mode 100644 packages/ndk/lib/domain_layer/usecases/nwc/requests/receive.dart create mode 100644 packages/ndk/lib/domain_layer/usecases/nwc/responses/pay_response.dart create mode 100644 packages/ndk/lib/domain_layer/usecases/nwc/responses/receive_response.dart create mode 100644 packages/ndk/test/entities/bip321_test.dart create mode 100644 packages/ndk/test/usecases/nwc/nwc_321_test.dart create mode 100644 packages/ndk/test/usecases/wallets_bip321_test.dart diff --git a/packages/ndk/example/nwc/README.md b/packages/ndk/example/nwc/README.md index 7e3c5a537..5a37fc360 100644 --- a/packages/ndk/example/nwc/README.md +++ b/packages/ndk/example/nwc/README.md @@ -9,3 +9,19 @@ see https://github.com/getAlby/awesome-nwc for more info how to get a wallet sup for more logging `NWC_URI=nostr+walletconnect://.... dart --enable-asserts connect_get_info.dart` + +## NWC-321 pay and receive + +Pay a BOLT11 invoice through a BIP-321 `lightning` instruction: + +`NWC_URI=nostr+walletconnect://.... INVOICE=lnbc... dart pay.dart` + +If the invoice has no amount, also provide `AMOUNT_MSAT`: + +`NWC_URI=nostr+walletconnect://.... INVOICE=lnbc... AMOUNT_MSAT=21000 dart pay.dart` + +Create a fixed-amount BIP-321 URI containing a BOLT11 instruction: + +`NWC_URI=nostr+walletconnect://.... AMOUNT_MSAT=21000 DESCRIPTION=hello dart receive.dart` + +Omit `AMOUNT_MSAT` to request a variable-amount URI. diff --git a/packages/ndk/example/nwc/connect_get_info.dart b/packages/ndk/example/nwc/connect_get_info.dart index 1844e2daa..8a0f84f9d 100644 --- a/packages/ndk/example/nwc/connect_get_info.dart +++ b/packages/ndk/example/nwc/connect_get_info.dart @@ -16,6 +16,7 @@ void main() async { if (connection.info != null) { print("alias: ${connection.info!.alias}"); + print("methods: ${connection.info!.methods}"); if (connection.info!.pubkey != null) { print("pubkey: ${connection.info!.pubkey}"); } diff --git a/packages/ndk/example/nwc/pay.dart b/packages/ndk/example/nwc/pay.dart new file mode 100644 index 000000000..faf57fef0 --- /dev/null +++ b/packages/ndk/example/nwc/pay.dart @@ -0,0 +1,39 @@ +// ignore_for_file: avoid_print + +import 'dart:io'; + +import 'package:ndk/ndk.dart'; + +void main() async { + final ndk = Ndk.emptyBootstrapRelaysConfig(); + + // Provide an NWC connection URI and the BOLT11 invoice to pay. + final nwcUri = Platform.environment['NWC_URI']!; + final invoice = Platform.environment['INVOICE']!; + final amountMsat = int.tryParse( + Platform.environment['AMOUNT_MSAT'] ?? '', + ); + + final connection = await ndk.nwc.connect(nwcUri); + + // NWC-321 expects a BIP-321 URI. This example contains only a BOLT11 + // `lightning` instruction. + final payment = Bip321.fromBolt11(invoice); + + final response = await ndk.nwc.pay( + connection, + payment: payment, + // Required only when the BOLT11 invoice has no amount. + amountMsat: amountMsat, + payerNote: Platform.environment['PAYER_NOTE'], + ); + + print('transaction id: ${response.transactionId}'); + print('state: ${response.state}'); + print('instruction type: ${response.instructionType}'); + print('amount: ${response.amountMsat} msats'); + print('fees paid: ${response.feesPaid} msats'); + print('preimage: ${response.preimage}'); + + await ndk.destroy(); +} diff --git a/packages/ndk/example/nwc/receive.dart b/packages/ndk/example/nwc/receive.dart new file mode 100644 index 000000000..dd9b7b9bf --- /dev/null +++ b/packages/ndk/example/nwc/receive.dart @@ -0,0 +1,29 @@ +// ignore_for_file: avoid_print + +import 'dart:io'; + +import 'package:ndk/ndk.dart'; + +void main() async { + final ndk = Ndk.emptyBootstrapRelaysConfig(); + + // Provide an NWC connection URI. Omit AMOUNT_MSAT for a variable amount. + final nwcUri = Platform.environment['NWC_URI']!; + final amountMsat = int.tryParse( + Platform.environment['AMOUNT_MSAT'] ?? '', + ); + + final connection = await ndk.nwc.connect(nwcUri); + final response = await ndk.nwc.receive( + connection, + amountMsat: amountMsat, + description: Platform.environment['DESCRIPTION'], + ); + + // For now, use a wallet whose `receive` implementation returns a BOLT11 + // `lightning` instruction in this BIP-321 URI. + print('BIP-321 URI: ${response.bip321}'); + print('transaction id: ${response.transactionId}'); + + await ndk.destroy(); +} diff --git a/packages/ndk/lib/domain_layer/entities/wallet/bip321.dart b/packages/ndk/lib/domain_layer/entities/wallet/bip321.dart new file mode 100644 index 000000000..49e68cc5c --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/wallet/bip321.dart @@ -0,0 +1,86 @@ +/// Helpers for BIP-321 URIs containing BOLT11 payment instructions. +class Bip321 { + Bip321._(); + + /// Creates a BIP-321 URI containing one BOLT11 `lightning` instruction. + static String fromBolt11(String invoice) { + if (!invoice.toLowerCase().startsWith('ln')) { + throw ArgumentError.value(invoice, 'invoice', 'Must be a BOLT11 invoice'); + } + return Uri( + scheme: 'bitcoin', + queryParameters: {'lightning': invoice}, + ).toString(); + } + + /// Selects the single BOLT11 `lightning` instruction from [payment]. + static String getBolt11(String payment) { + final Uri uri; + try { + uri = Uri.parse(payment); + } on FormatException { + throw FormatException('Invalid BIP-321 URI', payment); + } + + if (uri.scheme.toLowerCase() != 'bitcoin') { + throw FormatException('BIP-321 URI must use the bitcoin scheme', payment); + } + + final requiredParameters = uri.queryParametersAll.keys.where( + (key) => key.startsWith('req-'), + ); + if (requiredParameters.isNotEmpty) { + throw UnsupportedError( + 'Unsupported required BIP-321 parameter: ' + '${requiredParameters.first}', + ); + } + + final instructions = uri.queryParametersAll['lightning']; + if (instructions == null || + instructions.length != 1 || + instructions.single.isEmpty) { + throw const FormatException( + 'BIP-321 URI must contain one lightning instruction', + ); + } + + final invoice = instructions.single; + if (!invoice.toLowerCase().startsWith('ln')) { + throw const FormatException('Invalid BOLT11 lightning instruction'); + } + return invoice; + } + + /// Returns the BOLT11 amount in millisatoshis, or null when amountless. + static int? getBolt11AmountMsat(String invoice) { + final separator = invoice.toLowerCase().lastIndexOf('1'); + if (separator < 0) { + throw const FormatException('Invalid BOLT11 invoice'); + } + + final hrp = invoice.toLowerCase().substring(0, separator); + final match = RegExp( + r'^ln(?:bcrt|bc|tb|sb)([0-9]*)([munp]?)$', + ).firstMatch(hrp); + if (match == null) { + throw const FormatException('Invalid BOLT11 invoice prefix'); + } + + final digits = match.group(1)!; + if (digits.isEmpty) return null; + + final amount = int.parse(digits); + return switch (match.group(2)!) { + '' => amount * 100000000000, + 'm' => amount * 100000000, + 'u' => amount * 100000, + 'n' => amount * 100, + 'p' when amount % 10 == 0 => amount ~/ 10, + 'p' => throw const FormatException( + 'BOLT11 pico-bitcoin amount is not a whole millisatoshi', + ), + _ => throw const FormatException('Invalid BOLT11 amount multiplier'), + }; + } +} diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/cashu/cashu_wallet_provider.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/cashu/cashu_wallet_provider.dart index 093d9eba1..d8caa8fd3 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/providers/cashu/cashu_wallet_provider.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/cashu/cashu_wallet_provider.dart @@ -2,7 +2,10 @@ import 'dart:async'; import '../../../../usecases/cashu/cashu.dart'; import '../../../../usecases/nwc/responses/pay_invoice_response.dart'; +import '../../../../usecases/nwc/responses/pay_response.dart'; +import '../../../../usecases/nwc/responses/receive_response.dart'; import '../../../cashu/cashu_mint_info.dart'; +import '../../bip321.dart'; import '../../wallet.dart'; import '../../wallet_balance.dart'; import '../../wallet_provider.dart'; @@ -119,6 +122,16 @@ class CashuWalletProvider implements WalletProvider { throw ArgumentError('Expected a CashuWallet'); } + final result = await _payBolt11(wallet, invoice, timeout: timeout); + return result.legacyResponse; + } + + Future<_CashuBolt11Payment> _payBolt11( + CashuWallet wallet, + String invoice, { + int? expectedAmountMsat, + Duration? timeout, + }) async { final draftTransaction = await _cashuUseCase.initiateRedeem( mintUrl: wallet.mintUrl, request: invoice, @@ -126,9 +139,22 @@ class CashuWalletProvider implements WalletProvider { method: 'bolt11', ); - await for (final transaction in _cashuUseCase.redeem( + final amountMsat = draftTransaction.qouteMelt!.amount * 1000; + if (expectedAmountMsat != null && amountMsat != expectedAmountMsat) { + throw ArgumentError( + 'BIP-321 amount $expectedAmountMsat msats conflicts with ' + 'the BOLT11 invoice amount $amountMsat msats', + ); + } + + var transactions = _cashuUseCase.redeem( draftRedeemTransaction: draftTransaction, - )) { + ); + if (timeout != null) { + transactions = transactions.timeout(timeout); + } + + await for (final transaction in transactions) { if (transaction.state == WalletTransactionState.completed) { final int feesPaid; if (draftTransaction.qouteMelt?.feeReserve != null) { @@ -137,11 +163,19 @@ class CashuWalletProvider implements WalletProvider { feesPaid = 0; } - return PayInvoiceResponse( + final legacyResponse = PayInvoiceResponse( resultType: 'pay_invoice', preimage: null, feesPaid: feesPaid, ); + return _CashuBolt11Payment( + legacyResponse: legacyResponse, + transactionId: draftTransaction.id, + amountMsat: amountMsat, + createdAt: draftTransaction.initiatedDate ?? + DateTime.now().millisecondsSinceEpoch ~/ 1000, + settledAt: transaction.transactionDate, + ); } else if (transaction.state == WalletTransactionState.failed) { throw Exception('Cashu payment failed: ${transaction.completionMsg}'); } @@ -195,4 +229,111 @@ class CashuWalletProvider implements WalletProvider { return invoice; } + + @override + Future payBip321( + Wallet wallet, { + required String payment, + int? amountMsat, + String? payerNote, + Map? metadata, + Duration? timeout, + }) async { + if (wallet is! CashuWallet) { + throw ArgumentError('Expected a CashuWallet'); + } + if (payerNote?.isNotEmpty == true) { + throw UnsupportedError('BOLT11 does not support payer notes'); + } + + final invoice = Bip321.getBolt11(payment); + final invoiceAmountMsat = Bip321.getBolt11AmountMsat(invoice); + if (invoiceAmountMsat == null) { + throw UnsupportedError( + 'Cashu does not support paying amountless BOLT11 invoices', + ); + } + if (invoiceAmountMsat % 1000 != 0) { + throw UnsupportedError( + 'Cashu only supports whole-satoshi BOLT11 amounts', + ); + } + if (amountMsat != null && amountMsat != invoiceAmountMsat) { + throw ArgumentError( + 'BIP-321 amount $amountMsat msats conflicts with ' + 'the BOLT11 invoice amount $invoiceAmountMsat msats', + ); + } + + final result = await _payBolt11( + wallet, + invoice, + expectedAmountMsat: invoiceAmountMsat, + timeout: timeout, + ); + return PayResponse( + resultType: 'pay', + transactionId: result.transactionId, + state: 'settled', + instructionType: 'bolt11', + amountMsat: result.amountMsat, + feesPaid: result.legacyResponse.feesPaid, + preimage: result.legacyResponse.preimage, + createdAt: result.createdAt, + settledAt: result.settledAt, + ); + } + + @override + Future receiveBip321( + Wallet wallet, { + int? amountMsat, + String? description, + Map? metadata, + Duration? timeout, + }) async { + if (amountMsat == null) { + throw UnsupportedError( + 'Cashu does not support variable-amount BOLT11 invoices', + ); + } + if (amountMsat <= 0 || amountMsat % 1000 != 0) { + throw ArgumentError.value( + amountMsat, + 'amountMsat', + 'Cashu requires a positive whole-satoshi amount', + ); + } + if (description?.isNotEmpty == true) { + throw UnsupportedError( + 'Cashu does not support setting a BOLT11 description', + ); + } + + var invoiceFuture = receive(wallet, amountMsat ~/ 1000); + if (timeout != null) { + invoiceFuture = invoiceFuture.timeout(timeout); + } + final invoice = await invoiceFuture; + return ReceiveResponse( + resultType: 'receive', + bip321: Bip321.fromBolt11(invoice), + ); + } +} + +class _CashuBolt11Payment { + final PayInvoiceResponse legacyResponse; + final String transactionId; + final int amountMsat; + final int createdAt; + final int? settledAt; + + const _CashuBolt11Payment({ + required this.legacyResponse, + required this.transactionId, + required this.amountMsat, + required this.createdAt, + required this.settledAt, + }); } diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/lnurl/lnurl_wallet_provider.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/lnurl/lnurl_wallet_provider.dart index 1ac17f700..7a6a0be0f 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/providers/lnurl/lnurl_wallet_provider.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/lnurl/lnurl_wallet_provider.dart @@ -4,6 +4,9 @@ import 'package:ndk/domain_layer/usecases/lnurl/lnurl.dart'; import 'package:ndk/domain_layer/usecases/lnurl/lnurl_response.dart'; import 'package:ndk/shared/logger/logger.dart'; import 'package:ndk/domain_layer/usecases/nwc/responses/pay_invoice_response.dart'; +import 'package:ndk/domain_layer/usecases/nwc/responses/pay_response.dart'; +import 'package:ndk/domain_layer/usecases/nwc/responses/receive_response.dart'; +import '../../bip321.dart'; import '../../wallet.dart'; import '../../wallet_balance.dart'; import '../../wallet_provider.dart'; @@ -165,6 +168,57 @@ class LnurlWalletProvider implements WalletProvider { return invoiceResponse.invoice; } + @override + Future payBip321( + Wallet wallet, { + required String payment, + int? amountMsat, + String? payerNote, + Map? metadata, + Duration? timeout, + }) async { + throw UnsupportedError( + 'LNURL wallet is receive-only and cannot pay BIP-321 instructions', + ); + } + + @override + Future receiveBip321( + Wallet wallet, { + int? amountMsat, + String? description, + Map? metadata, + Duration? timeout, + }) async { + if (amountMsat == null) { + throw UnsupportedError( + 'LNURL does not support variable-amount BOLT11 invoices', + ); + } + if (amountMsat <= 0 || amountMsat % 1000 != 0) { + throw ArgumentError.value( + amountMsat, + 'amountMsat', + 'LNURL requires a positive whole-satoshi amount', + ); + } + if (description?.isNotEmpty == true) { + throw UnsupportedError( + 'LNURL does not support overriding the BOLT11 description', + ); + } + + var invoiceFuture = receive(wallet, amountMsat ~/ 1000); + if (timeout != null) { + invoiceFuture = invoiceFuture.timeout(timeout); + } + final invoice = await invoiceFuture; + return ReceiveResponse( + resultType: 'receive', + bip321: Bip321.fromBolt11(invoice), + ); + } + @override Stream> get discoveredWallets { // LNURL wallets are not auto-discovered diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart index dfd8482b6..4f9ed2be2 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart @@ -101,9 +101,11 @@ class NwcWallet extends Wallet { @override bool get canReceive => - _effectivePermissions.contains(NwcMethod.MAKE_INVOICE.name); + _effectivePermissions.contains(NwcMethod.MAKE_INVOICE.name) || + _effectivePermissions.contains(NwcMethod.RECEIVE.name); @override bool get canSend => - _effectivePermissions.contains(NwcMethod.PAY_INVOICE.name); + _effectivePermissions.contains(NwcMethod.PAY_INVOICE.name) || + _effectivePermissions.contains(NwcMethod.PAY.name); } diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet_provider.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet_provider.dart index 12de67fae..1f300a630 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet_provider.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet_provider.dart @@ -6,6 +6,8 @@ import '../../../../usecases/nwc/consts/nwc_method.dart'; import '../../../../usecases/nwc/nwc.dart'; import '../../../../usecases/nwc/nwc_connection.dart'; import '../../../../usecases/nwc/responses/pay_invoice_response.dart'; +import '../../../../usecases/nwc/responses/pay_response.dart'; +import '../../../../usecases/nwc/responses/receive_response.dart'; import '../../wallet.dart'; import '../../wallet_balance.dart'; import '../../wallet_provider.dart'; @@ -186,6 +188,58 @@ class NwcWalletProvider implements WalletProvider { return response.invoice; } + @override + Future payBip321( + Wallet wallet, { + required String payment, + int? amountMsat, + String? payerNote, + Map? metadata, + Duration? timeout, + }) async { + final nwcWallet = wallet as NwcWallet; + + await initialize(wallet); + final connection = _connectionOrThrow(nwcWallet); + + final response = await _nwcUseCase.pay( + connection, + payment: payment, + amountMsat: amountMsat, + payerNote: payerNote, + metadata: metadata, + timeout: timeout, + ); + try { + await _refreshAll(nwcWallet); + } catch (_) { + // The payment succeeded; optional refresh methods must not hide it. + } + return response; + } + + @override + Future receiveBip321( + Wallet wallet, { + int? amountMsat, + String? description, + Map? metadata, + Duration? timeout, + }) async { + final nwcWallet = wallet as NwcWallet; + + await initialize(wallet); + final connection = _connectionOrThrow(nwcWallet); + + return _nwcUseCase.receive( + connection, + amountMsat: amountMsat, + description: description, + metadata: metadata, + timeout: timeout, + ); + } + Future _refreshAll(NwcWallet wallet) async { if (_refreshInFlight[wallet.id] == true) { return; diff --git a/packages/ndk/lib/domain_layer/entities/wallet/wallet_provider.dart b/packages/ndk/lib/domain_layer/entities/wallet/wallet_provider.dart index 78175f125..c063820a0 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/wallet_provider.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/wallet_provider.dart @@ -1,4 +1,6 @@ import '../../usecases/nwc/responses/pay_invoice_response.dart'; +import '../../usecases/nwc/responses/pay_response.dart'; +import '../../usecases/nwc/responses/receive_response.dart'; import 'wallet.dart'; import 'wallet_balance.dart'; import 'wallet_transaction.dart'; @@ -49,6 +51,25 @@ abstract class WalletProvider { /// Receive by creating a Lightning Invoice Future receive(Wallet wallet, int amountSats); + /// Pays a payment instruction selected from a BIP-321 URI. + Future payBip321( + Wallet wallet, { + required String payment, + int? amountMsat, + String? payerNote, + Map? metadata, + Duration? timeout, + }); + + /// Creates a BIP-321 URI using a provider-supported payment instruction. + Future receiveBip321( + Wallet wallet, { + int? amountMsat, + String? description, + Map? metadata, + Duration? timeout, + }); + /// Stream of wallets discovered by this provider /// For auto-discovery (e.g., Cashu mints, NWC connections from events) Stream> get discoveredWallets; diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/consts/error_code.dart b/packages/ndk/lib/domain_layer/usecases/nwc/consts/error_code.dart index f6b94a140..38184febc 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/consts/error_code.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/consts/error_code.dart @@ -1,4 +1,13 @@ enum ErrorCode { + badRequest('BAD_REQUEST', 'The request contains an invalid parameter.'), + unsupportedPaymentInstruction( + 'UNSUPPORTED_PAYMENT_INSTRUCTION', + 'The wallet cannot select a supported payment instruction.', + ), + unsupportedNetwork( + 'UNSUPPORTED_NETWORK', + 'The payment instruction uses a different Bitcoin network.', + ), rateLimited( 'RATE_LIMITED', 'The client is sending commands too fast. It should retry in a few seconds.', diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/consts/nwc_method.dart b/packages/ndk/lib/domain_layer/usecases/nwc/consts/nwc_method.dart index 459d7f383..2c60fedb5 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/consts/nwc_method.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/consts/nwc_method.dart @@ -10,6 +10,8 @@ class NwcMethod { static const NwcMethod GET_INFO = NwcMethod('get_info'); static const NwcMethod GET_BALANCE = NwcMethod('get_balance'); static const NwcMethod GET_BUDGET = NwcMethod('get_budget'); + static const NwcMethod PAY = NwcMethod('pay'); + static const NwcMethod RECEIVE = NwcMethod('receive'); static const NwcMethod PAY_INVOICE = NwcMethod('pay_invoice'); static const NwcMethod MULTI_PAY_INVOICE = NwcMethod('multi_pay_invoice'); static const NwcMethod PAY_KEYSEND = NwcMethod('pay_keysend'); @@ -25,6 +27,8 @@ class NwcMethod { // Registry to store all methods by their plaintext static final Map _methodsRegistry = { + PAY.name: PAY, + RECEIVE.name: RECEIVE, PAY_INVOICE.name: PAY_INVOICE, MULTI_PAY_INVOICE.name: MULTI_PAY_INVOICE, PAY_KEYSEND.name: PAY_KEYSEND, diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart b/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart index 721fdf004..76114788e 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart @@ -18,7 +18,9 @@ import 'requests/make_hold_invoice.dart'; // Add import for MakeHoldInvoiceReque import 'requests/cancel_hold_invoice.dart'; // Add import for CancelHoldInvoiceRequest import 'requests/settle_hold_invoice.dart'; // Add import for SettleHoldInvoiceRequest import 'requests/nwc_request.dart'; +import 'requests/pay.dart'; import 'requests/pay_invoice.dart'; +import 'requests/receive.dart'; import 'responses/nwc_response.dart'; /// Main entry point for the NWC (Nostr Wallet Connect - NIP47 ) usecase @@ -213,6 +215,10 @@ class Nwc { response = MakeInvoiceResponse.deserialize(data); } else if (data['result_type'] == NwcMethod.PAY_INVOICE.name) { response = PayInvoiceResponse.deserialize(data); + } else if (data['result_type'] == NwcMethod.PAY.name) { + response = PayResponse.deserialize(data); + } else if (data['result_type'] == NwcMethod.RECEIVE.name) { + response = ReceiveResponse.deserialize(data); } else if (data['result_type'] == NwcMethod.LIST_TRANSACTIONS.name) { response = ListTransactionsResponse.deserialize(data); } else if (data['result_type'] == NwcMethod.LOOKUP_INVOICE.name) { @@ -519,6 +525,46 @@ class Nwc { ); } + /// Pays a Lightning instruction from a BIP-321 URI using NWC-321. + Future pay( + NwcConnection connection, { + required String payment, + int? amountMsat, + String? payerNote, + Map? metadata, + Duration? timeout, + }) async { + return _executeRequest( + connection, + PayRequest( + payment: payment, + amountMsat: amountMsat, + payerNote: payerNote, + metadata: metadata, + ), + timeout: timeout, + ); + } + + /// Creates a BIP-321 URI containing a Lightning receive instruction. + Future receive( + NwcConnection connection, { + int? amountMsat, + String? description, + Map? metadata, + Duration? timeout, + }) async { + return _executeRequest( + connection, + ReceiveRequest( + amountMsat: amountMsat, + description: description, + metadata: metadata, + ), + timeout: timeout, + ); + } + /// Does a `lookup_invoice` request Future lookupInvoice( NwcConnection connection, { diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/requests/pay.dart b/packages/ndk/lib/domain_layer/usecases/nwc/requests/pay.dart new file mode 100644 index 000000000..5973fee7f --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/nwc/requests/pay.dart @@ -0,0 +1,38 @@ +import 'package:ndk/domain_layer/usecases/nwc/consts/nwc_method.dart'; + +import 'nwc_request.dart'; + +/// Request to pay a Lightning instruction from a BIP-321 URI. +class PayRequest extends NwcRequest { + /// The BIP-321 payment URI. + final String payment; + + /// The amount to pay in millisatoshis when the instruction has no amount. + final int? amountMsat; + + /// An optional message from the payer. + final String? payerNote; + + /// Optional application-defined metadata. + final Map? metadata; + + const PayRequest({ + required this.payment, + this.amountMsat, + this.payerNote, + this.metadata, + }) : super(method: NwcMethod.PAY); + + @override + Map toMap() { + return { + ...super.toMap(), + 'params': { + 'payment': payment, + if (amountMsat != null) 'amount': amountMsat, + if (payerNote != null) 'payer_note': payerNote, + if (metadata != null) 'metadata': metadata, + }, + }; + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/requests/receive.dart b/packages/ndk/lib/domain_layer/usecases/nwc/requests/receive.dart new file mode 100644 index 000000000..e4f635f7d --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/nwc/requests/receive.dart @@ -0,0 +1,30 @@ +import 'package:ndk/domain_layer/usecases/nwc/consts/nwc_method.dart'; + +import 'nwc_request.dart'; + +/// Request to create a BIP-321 URI containing a Lightning receive instruction. +class ReceiveRequest extends NwcRequest { + /// The requested amount in millisatoshis, or null for a variable amount. + final int? amountMsat; + + /// An optional description for the payment instruction. + final String? description; + + /// Optional application-defined metadata. + final Map? metadata; + + const ReceiveRequest({this.amountMsat, this.description, this.metadata}) + : super(method: NwcMethod.RECEIVE); + + @override + Map toMap() { + return { + ...super.toMap(), + 'params': { + if (amountMsat != null) 'amount': amountMsat, + if (description != null) 'description': description, + if (metadata != null) 'metadata': metadata, + }, + }; + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/responses/pay_response.dart b/packages/ndk/lib/domain_layer/usecases/nwc/responses/pay_response.dart new file mode 100644 index 000000000..dd7b4a972 --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/nwc/responses/pay_response.dart @@ -0,0 +1,83 @@ +import 'nwc_response.dart'; + +/// Represents the result of a NWC-321 `pay` response. +class PayResponse extends NwcResponse { + /// Wallet-scoped transaction identifier. + final String transactionId; + + /// Payment state: `pending`, `settled`, or `failed`. + final String state; + + /// Selected instruction type. Currently only `bolt11` is supported. + final String instructionType; + + /// Paid amount in millisatoshis. + final int amountMsat; + + /// Paid fees in millisatoshis. + final int feesPaid; + + /// Payment hash, when available. + final String? paymentHash; + + /// Payment preimage, when available. + final String? preimage; + + /// Proof supplied by the selected instruction, when available. + final String? payerProof; + + /// On-chain transaction identifier, when available. + final String? txid; + + /// Failure details. This is expected when [state] is `failed`. + final String? failureReason; + + /// Unix timestamp when the transaction was created. + final int createdAt; + + /// Unix timestamp when the transaction settled, when applicable. + final int? settledAt; + + PayResponse({ + required super.resultType, + required this.transactionId, + required this.state, + required this.instructionType, + required this.amountMsat, + required this.feesPaid, + required this.createdAt, + this.paymentHash, + this.preimage, + this.payerProof, + this.txid, + this.failureReason, + this.settledAt, + }); + + /// Paid amount rounded down to satoshis. + int get amountSat => amountMsat ~/ 1000; + + factory PayResponse.deserialize(Map input) { + if (!input.containsKey('result')) { + throw Exception('Invalid input'); + } + + final result = input['result'] as Map; + + return PayResponse( + resultType: input['result_type'] as String, + transactionId: result['transaction_id'] as String, + state: result['state'] as String, + instructionType: result['instruction_type'] as String, + amountMsat: result['amount'] as int, + feesPaid: result['fees_paid'] as int, + paymentHash: result['payment_hash'] as String?, + preimage: result['preimage'] as String?, + payerProof: result['payer_proof'] as String?, + txid: result['txid'] as String?, + failureReason: result['failure_reason'] as String?, + createdAt: result['created_at'] as int, + settledAt: result['settled_at'] as int?, + ); + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/responses/receive_response.dart b/packages/ndk/lib/domain_layer/usecases/nwc/responses/receive_response.dart new file mode 100644 index 000000000..427837d20 --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/nwc/responses/receive_response.dart @@ -0,0 +1,30 @@ +import 'nwc_response.dart'; + +/// Represents the result of a NWC-321 `receive` response. +class ReceiveResponse extends NwcResponse { + /// BIP-321 URI containing one or more receive instructions. + final String bip321; + + /// Wallet-scoped transaction identifier, when one was allocated. + final String? transactionId; + + ReceiveResponse({ + required super.resultType, + required this.bip321, + this.transactionId, + }); + + factory ReceiveResponse.deserialize(Map input) { + if (!input.containsKey('result')) { + throw Exception('Invalid input'); + } + + final result = input['result'] as Map; + + return ReceiveResponse( + resultType: input['result_type'] as String, + bip321: result['bip321'] as String, + transactionId: result['transaction_id'] as String?, + ); + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart b/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart index c501cf692..b1c6b9f89 100644 --- a/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart +++ b/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart @@ -10,6 +10,8 @@ import '../../entities/wallet/wallet_transaction.dart'; import '../../entities/wallet/wallet_type.dart'; import '../../repositories/wallets_repo.dart'; import '../../usecases/nwc/responses/pay_invoice_response.dart'; +import '../../usecases/nwc/responses/pay_response.dart'; +import '../../usecases/nwc/responses/receive_response.dart'; /// Unified wallet system that handles multiple wallet types (NWC, Cashu, etc.) /// Uses WalletProvider pattern for pluggability @@ -585,6 +587,62 @@ class Wallets { return provider.receive(wallet, amountSats); } + /// Pays an instruction from a BIP-321 URI using the selected wallet. + Future payBip321({ + String? walletId, + required String payment, + int? amountMsat, + String? payerNote, + Map? metadata, + Duration? timeout, + }) async { + await _initializationFuture; + walletId ??= _repository.getDefaultWalletIdForSending(); + if (walletId == null) { + throw StateError('No default wallet set'); + } + final wallet = await _getWalletForOperation(walletId); + final provider = _providers[wallet.type]; + if (provider == null) { + throw ArgumentError('No provider for wallet type: ${wallet.type}'); + } + return provider.payBip321( + wallet, + payment: payment, + amountMsat: amountMsat, + payerNote: payerNote, + metadata: metadata, + timeout: timeout, + ); + } + + /// Creates a BIP-321 URI using the selected receiving wallet. + Future receiveBip321({ + String? walletId, + int? amountMsat, + String? description, + Map? metadata, + Duration? timeout, + }) async { + await _initializationFuture; + walletId ??= _repository.getDefaultWalletIdForReceiving(); + if (walletId == null) { + throw StateError('No default wallet set'); + } + final wallet = await _getWalletForOperation(walletId); + final provider = _providers[wallet.type]; + if (provider == null) { + throw ArgumentError('No provider for wallet type: ${wallet.type}'); + } + return provider.receiveBip321( + wallet, + amountMsat: amountMsat, + description: description, + metadata: metadata, + timeout: timeout, + ); + } + Future _getWalletForOperation(String walletId) async { final inMemory = _wallets.firstWhereOrNull( (wallet) => wallet.id == walletId, diff --git a/packages/ndk/lib/entities.dart b/packages/ndk/lib/entities.dart index b3e8a7015..2b3cb539b 100644 --- a/packages/ndk/lib/entities.dart +++ b/packages/ndk/lib/entities.dart @@ -56,6 +56,7 @@ export 'domain_layer/entities/wallet/wallet.dart'; export 'domain_layer/entities/wallet/wallet_transaction.dart'; export 'domain_layer/entities/wallet/wallet_type.dart'; export 'domain_layer/entities/wallet/wallet_balance.dart'; +export 'domain_layer/entities/wallet/bip321.dart'; export 'domain_layer/entities/wallet/wallet_factory.dart'; export 'domain_layer/entities/wallet/providers/cashu/cashu_wallet.dart'; export 'domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart'; diff --git a/packages/ndk/lib/ndk.dart b/packages/ndk/lib/ndk.dart index fd573528a..939a3d578 100644 --- a/packages/ndk/lib/ndk.dart +++ b/packages/ndk/lib/ndk.dart @@ -35,6 +35,9 @@ export 'domain_layer/usecases/nwc/responses/get_budget_response.dart'; export 'domain_layer/usecases/nwc/responses/get_info_response.dart'; export 'domain_layer/usecases/nwc/responses/make_invoice_response.dart'; export 'domain_layer/usecases/nwc/responses/pay_invoice_response.dart'; +export 'domain_layer/usecases/nwc/responses/pay_response.dart'; +export 'domain_layer/usecases/nwc/responses/receive_response.dart'; +export 'domain_layer/entities/wallet/bip321.dart'; export 'domain_layer/usecases/nwc/responses/list_transactions_response.dart'; export 'domain_layer/usecases/nwc/responses/lookup_invoice_response.dart'; export 'domain_layer/usecases/nwc/nwc_connection.dart'; diff --git a/packages/ndk/test/entities/bip321_test.dart b/packages/ndk/test/entities/bip321_test.dart new file mode 100644 index 000000000..ab500e2b4 --- /dev/null +++ b/packages/ndk/test/entities/bip321_test.dart @@ -0,0 +1,46 @@ +import 'package:ndk/domain_layer/entities/wallet/bip321.dart'; +import 'package:test/test.dart'; + +void main() { + group('Bip321', () { + test('round-trips a BOLT11 instruction', () { + const invoice = 'lnbc210n1paymentdata'; + + final payment = Bip321.fromBolt11(invoice); + + expect(payment, 'bitcoin:?lightning=lnbc210n1paymentdata'); + expect(Bip321.getBolt11(payment), invoice); + }); + + test('decodes BOLT11 amounts in millisatoshis', () { + expect(Bip321.getBolt11AmountMsat('lnbc1paymentdata'), isNull); + expect(Bip321.getBolt11AmountMsat('lnbc210n1paymentdata'), 21000); + expect(Bip321.getBolt11AmountMsat('lnbc2m1paymentdata'), 200000000); + expect(Bip321.getBolt11AmountMsat('lntb3u1paymentdata'), 300000); + expect(Bip321.getBolt11AmountMsat('lnbcrt4n1paymentdata'), 400); + expect(Bip321.getBolt11AmountMsat('lnsb50p1paymentdata'), 5); + }); + + test('rejects unknown required parameters', () { + expect( + () => Bip321.getBolt11( + 'bitcoin:?lightning=lnbc1paymentdata&req-example=value', + ), + throwsUnsupportedError, + ); + }); + + test('rejects missing and duplicate lightning instructions', () { + expect( + () => Bip321.getBolt11('bitcoin:?amount=1'), + throwsFormatException, + ); + expect( + () => Bip321.getBolt11( + 'bitcoin:?lightning=lnbc1first&lightning=lnbc1second', + ), + throwsFormatException, + ); + }); + }); +} diff --git a/packages/ndk/test/entities/nwc_wallet_test.dart b/packages/ndk/test/entities/nwc_wallet_test.dart index b8c25d01c..0bad3c59c 100644 --- a/packages/ndk/test/entities/nwc_wallet_test.dart +++ b/packages/ndk/test/entities/nwc_wallet_test.dart @@ -46,5 +46,24 @@ void main() { NwcMethod.PAY_INVOICE.name, ]); }); + + test('pay and receive permissions enable wallet operations', () { + final wallet = NwcWallet.fromStorage( + id: 'w1', + name: 'NWC', + supportedUnits: {'sat'}, + metadata: { + 'nwcUrl': + 'nostr+walletconnect://a?relay=wss://relay.example&secret=secret', + NwcWallet.kPermissionsMetadataKey: [ + NwcMethod.PAY.name, + NwcMethod.RECEIVE.name, + ], + }, + ); + + expect(wallet.canSend, isTrue); + expect(wallet.canReceive, isTrue); + }); }); } diff --git a/packages/ndk/test/usecases/nwc/nwc_321_test.dart b/packages/ndk/test/usecases/nwc/nwc_321_test.dart new file mode 100644 index 000000000..80c7f82e7 --- /dev/null +++ b/packages/ndk/test/usecases/nwc/nwc_321_test.dart @@ -0,0 +1,162 @@ +import 'package:ndk/domain_layer/usecases/nwc/consts/error_code.dart'; +import 'package:ndk/domain_layer/usecases/nwc/requests/pay.dart'; +import 'package:ndk/domain_layer/usecases/nwc/requests/receive.dart'; +import 'package:ndk/domain_layer/usecases/nwc/responses/pay_response.dart'; +import 'package:ndk/domain_layer/usecases/nwc/responses/receive_response.dart'; +import 'package:test/test.dart'; + +void main() { + group('NWC-321 errors', () { + test('maps extension error codes', () { + expect(ErrorCode.fromValue('BAD_REQUEST'), ErrorCode.badRequest); + expect( + ErrorCode.fromValue('UNSUPPORTED_PAYMENT_INSTRUCTION'), + ErrorCode.unsupportedPaymentInstruction, + ); + expect( + ErrorCode.fromValue('UNSUPPORTED_NETWORK'), + ErrorCode.unsupportedNetwork, + ); + }); + }); + + group('PayRequest', () { + test('serializes all NWC-321 parameters', () { + const request = PayRequest( + payment: 'bitcoin:?lightning=lnbc1invoice', + amountMsat: 123000, + payerNote: 'Thanks', + metadata: {'order_id': '123'}, + ); + + expect(request.toMap(), { + 'method': 'pay', + 'params': { + 'payment': 'bitcoin:?lightning=lnbc1invoice', + 'amount': 123000, + 'payer_note': 'Thanks', + 'metadata': {'order_id': '123'}, + }, + }); + }); + + test('omits optional parameters', () { + const request = PayRequest( + payment: 'bitcoin:?lightning=lnbc1invoice', + ); + + expect(request.toMap(), { + 'method': 'pay', + 'params': {'payment': 'bitcoin:?lightning=lnbc1invoice'}, + }); + }); + }); + + group('ReceiveRequest', () { + test('serializes all NWC-321 parameters', () { + const request = ReceiveRequest( + amountMsat: 123000, + description: 'Coffee', + metadata: {'order_id': '123'}, + ); + + expect(request.toMap(), { + 'method': 'receive', + 'params': { + 'amount': 123000, + 'description': 'Coffee', + 'metadata': {'order_id': '123'}, + }, + }); + }); + + test('omits amount for a variable-amount instruction', () { + const request = ReceiveRequest(); + + expect(request.toMap(), {'method': 'receive', 'params': {}}); + }); + }); + + group('PayResponse', () { + test('deserializes a settled bolt11 payment', () { + final response = PayResponse.deserialize({ + 'result_type': 'pay', + 'result': { + 'transaction_id': 'transaction-1', + 'state': 'settled', + 'instruction_type': 'bolt11', + 'amount': 123456, + 'fees_paid': 1000, + 'payment_hash': 'payment-hash', + 'preimage': 'preimage', + 'payer_proof': 'proof', + 'txid': 'txid', + 'failure_reason': null, + 'created_at': 1700000000, + 'settled_at': 1700000001, + }, + }); + + expect(response.resultType, 'pay'); + expect(response.transactionId, 'transaction-1'); + expect(response.state, 'settled'); + expect(response.instructionType, 'bolt11'); + expect(response.amountMsat, 123456); + expect(response.amountSat, 123); + expect(response.feesPaid, 1000); + expect(response.paymentHash, 'payment-hash'); + expect(response.preimage, 'preimage'); + expect(response.payerProof, 'proof'); + expect(response.txid, 'txid'); + expect(response.failureReason, isNull); + expect(response.createdAt, 1700000000); + expect(response.settledAt, 1700000001); + }); + + test('deserializes optional fields when absent', () { + final response = PayResponse.deserialize({ + 'result_type': 'pay', + 'result': { + 'transaction_id': 'transaction-1', + 'state': 'pending', + 'instruction_type': 'bolt11', + 'amount': 123000, + 'fees_paid': 0, + 'created_at': 1700000000, + }, + }); + + expect(response.paymentHash, isNull); + expect(response.preimage, isNull); + expect(response.payerProof, isNull); + expect(response.txid, isNull); + expect(response.failureReason, isNull); + expect(response.settledAt, isNull); + }); + }); + + group('ReceiveResponse', () { + test('deserializes a bolt11 BIP-321 URI', () { + final response = ReceiveResponse.deserialize({ + 'result_type': 'receive', + 'result': { + 'bip321': 'bitcoin:?lightning=lnbc1invoice', + 'transaction_id': 'transaction-1', + }, + }); + + expect(response.resultType, 'receive'); + expect(response.bip321, 'bitcoin:?lightning=lnbc1invoice'); + expect(response.transactionId, 'transaction-1'); + }); + + test('allows an absent transaction identifier', () { + final response = ReceiveResponse.deserialize({ + 'result_type': 'receive', + 'result': {'bip321': 'bitcoin:?lightning=lnbc1invoice'}, + }); + + expect(response.transactionId, isNull); + }); + }); +} diff --git a/packages/ndk/test/usecases/nwc/nwc_method_test.dart b/packages/ndk/test/usecases/nwc/nwc_method_test.dart index 013876492..11115c703 100644 --- a/packages/ndk/test/usecases/nwc/nwc_method_test.dart +++ b/packages/ndk/test/usecases/nwc/nwc_method_test.dart @@ -13,6 +13,8 @@ void main() { NwcMethod.fromPlaintext('get_budget'), equals(NwcMethod.GET_BUDGET), ); + expect(NwcMethod.fromPlaintext('pay'), equals(NwcMethod.PAY)); + expect(NwcMethod.fromPlaintext('receive'), equals(NwcMethod.RECEIVE)); expect( NwcMethod.fromPlaintext('pay_invoice'), equals(NwcMethod.PAY_INVOICE), diff --git a/packages/ndk/test/usecases/wallets_bip321_test.dart b/packages/ndk/test/usecases/wallets_bip321_test.dart new file mode 100644 index 000000000..c8608bd03 --- /dev/null +++ b/packages/ndk/test/usecases/wallets_bip321_test.dart @@ -0,0 +1,189 @@ +import 'package:ndk/data_layer/repositories/wallets/mem_wallets_repo.dart'; +import 'package:ndk/domain_layer/entities/wallet/wallet.dart'; +import 'package:ndk/domain_layer/entities/wallet/wallet_balance.dart'; +import 'package:ndk/domain_layer/entities/wallet/wallet_provider.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/usecases/nwc/responses/pay_invoice_response.dart'; +import 'package:ndk/domain_layer/usecases/nwc/responses/pay_response.dart'; +import 'package:ndk/domain_layer/usecases/nwc/responses/receive_response.dart'; +import 'package:ndk/domain_layer/usecases/wallets/wallets.dart'; +import 'package:test/test.dart'; + +void main() { + test('Wallets delegates BIP-321 pay and receive to the wallet provider', + () async { + final wallet = _TestWallet(); + final repository = MemWalletsRepo(); + await repository.storeWallet(wallet); + repository.setDefaultWalletForSending(wallet.id); + repository.setDefaultWalletForReceiving(wallet.id); + + final provider = _TestWalletProvider(wallet); + final wallets = Wallets(providers: [provider], repository: repository); + addTearDown(wallets.dispose); + + final payResponse = await wallets.payBip321( + payment: 'bitcoin:?lightning=lnbc1invoice', + amountMsat: 21000, + payerNote: 'Thanks', + metadata: {'order_id': '123'}, + timeout: const Duration(seconds: 10), + ); + + expect(payResponse, same(provider.payResponse)); + expect(provider.paidWithWallet, same(wallet)); + expect(provider.payment, 'bitcoin:?lightning=lnbc1invoice'); + expect(provider.payAmountMsat, 21000); + expect(provider.payerNote, 'Thanks'); + expect(provider.payMetadata, {'order_id': '123'}); + expect(provider.payTimeout, const Duration(seconds: 10)); + + final receiveResponse = await wallets.receiveBip321( + amountMsat: 42000, + description: 'Coffee', + metadata: {'order_id': '456'}, + timeout: const Duration(seconds: 15), + ); + + expect(receiveResponse, same(provider.receiveResponse)); + expect(provider.receivedWithWallet, same(wallet)); + expect(provider.receiveAmountMsat, 42000); + expect(provider.description, 'Coffee'); + expect(provider.receiveMetadata, {'order_id': '456'}); + expect(provider.receiveTimeout, const Duration(seconds: 15)); + }); +} + +class _TestWallet extends Wallet { + _TestWallet() + : super( + id: 'wallet-1', + name: 'Test wallet', + type: WalletType.NWC, + supportedUnits: const {'sat'}, + metadata: const {}, + ); + + @override + bool get canReceive => true; + + @override + bool get canSend => true; + + @override + Map toMetadata() => metadata; +} + +class _TestWalletProvider extends WalletProvider { + final Wallet wallet; + + _TestWalletProvider(this.wallet); + + final payResponse = PayResponse( + resultType: 'pay', + transactionId: 'pay-transaction', + state: 'settled', + instructionType: 'bolt11', + amountMsat: 21000, + feesPaid: 1000, + createdAt: 1700000000, + ); + + final receiveResponse = ReceiveResponse( + resultType: 'receive', + bip321: 'bitcoin:?lightning=lnbc1invoice', + transactionId: 'receive-transaction', + ); + + Wallet? paidWithWallet; + String? payment; + int? payAmountMsat; + String? payerNote; + Map? payMetadata; + Duration? payTimeout; + + Wallet? receivedWithWallet; + int? receiveAmountMsat; + String? description; + Map? receiveMetadata; + Duration? receiveTimeout; + + @override + WalletType get type => WalletType.NWC; + + @override + Wallet createWallet({ + required String id, + required String name, + required Set supportedUnits, + required Map metadata, + }) => + wallet; + + @override + Stream> get discoveredWallets => Stream.value(const []); + + @override + Stream> getBalances(Wallet wallet) => + Stream.value(const []); + + @override + Stream> getPendingTransactions(Wallet wallet) => + Stream.value(const []); + + @override + Stream> getRecentTransactions(Wallet wallet) => + Stream.value(const []); + + @override + Future initialize(Wallet wallet) async => null; + + @override + Future removeWallet(Wallet wallet) async {} + + @override + Future send( + Wallet wallet, + String invoice, { + Duration? timeout, + }) async => + PayInvoiceResponse(resultType: 'pay_invoice', feesPaid: 0); + + @override + Future receive(Wallet wallet, int amountSats) async => 'invoice'; + + @override + Future payBip321( + Wallet wallet, { + required String payment, + int? amountMsat, + String? payerNote, + Map? metadata, + Duration? timeout, + }) async { + paidWithWallet = wallet; + this.payment = payment; + payAmountMsat = amountMsat; + this.payerNote = payerNote; + payMetadata = metadata; + payTimeout = timeout; + return payResponse; + } + + @override + Future receiveBip321( + Wallet wallet, { + int? amountMsat, + String? description, + Map? metadata, + Duration? timeout, + }) async { + receivedWithWallet = wallet; + receiveAmountMsat = amountMsat; + this.description = description; + receiveMetadata = metadata; + receiveTimeout = timeout; + return receiveResponse; + } +} From 1d19078c41f86f0bdb4a1be3ef4e4ad225c0f06f Mon Sep 17 00:00:00 2001 From: fmar Date: Fri, 21 Aug 2026 14:27:22 +0200 Subject: [PATCH 02/22] feat(nwc): add optional maxFeeMsat to pay (NWC-321 max_fee) Implements the `max_fee` parameter proposed for the NWC-321 `pay` method, mirroring the NIP-47 `pay_invoice` addition. Also adds the `FEE_LIMIT_EXCEEDED` error code. Wallets that support the parameter will not send payments whose routing fee exceeds the budget; wallets that don't implement it ignore the parameter per spec. --- .../ndk/lib/domain_layer/usecases/nwc/consts/error_code.dart | 4 ++++ packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart | 2 ++ packages/ndk/lib/domain_layer/usecases/nwc/requests/pay.dart | 5 +++++ 3 files changed, 11 insertions(+) diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/consts/error_code.dart b/packages/ndk/lib/domain_layer/usecases/nwc/consts/error_code.dart index 38184febc..8cb43a3c9 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/consts/error_code.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/consts/error_code.dart @@ -38,6 +38,10 @@ enum ErrorCode { ), unauthorized('UNAUTHORIZED', 'This public key has no wallet connected.'), internal('INTERNAL', 'An internal error.'), + feeLimitExceeded( + 'FEE_LIMIT_EXCEEDED', + 'No route fit the max_fee budget and no payment was attempted.', + ), other('OTHER', 'Other error.'); final String value; diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart b/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart index 648670f89..db7ac06ab 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart @@ -526,6 +526,7 @@ class Nwc { NwcConnection connection, { required String payment, int? amountMsat, + int? maxFeeMsat, String? payerNote, Map? metadata, Duration? timeout, @@ -535,6 +536,7 @@ class Nwc { PayRequest( payment: payment, amountMsat: amountMsat, + maxFeeMsat: maxFeeMsat, payerNote: payerNote, metadata: metadata, ), diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/requests/pay.dart b/packages/ndk/lib/domain_layer/usecases/nwc/requests/pay.dart index 5973fee7f..ef83770d8 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/requests/pay.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/requests/pay.dart @@ -10,6 +10,9 @@ class PayRequest extends NwcRequest { /// The amount to pay in millisatoshis when the instruction has no amount. final int? amountMsat; + /// The maximum routing fee the sender is willing to pay, in millisatoshis. + final int? maxFeeMsat; + /// An optional message from the payer. final String? payerNote; @@ -19,6 +22,7 @@ class PayRequest extends NwcRequest { const PayRequest({ required this.payment, this.amountMsat, + this.maxFeeMsat, this.payerNote, this.metadata, }) : super(method: NwcMethod.PAY); @@ -30,6 +34,7 @@ class PayRequest extends NwcRequest { 'params': { 'payment': payment, if (amountMsat != null) 'amount': amountMsat, + if (maxFeeMsat != null) 'max_fee': maxFeeMsat, if (payerNote != null) 'payer_note': payerNote, if (metadata != null) 'metadata': metadata, }, From fe41c633b1d94ca79af9d745652ad0d06b8c8d8f Mon Sep 17 00:00:00 2001 From: fmar Date: Thu, 27 Aug 2026 16:23:11 +0200 Subject: [PATCH 03/22] 0.9.2 --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a79c174a1..3c579a0b8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,7 @@ on: branches: [ master ] env: - FLUTTER_VERSION: "3.41.4" + FLUTTER_VERSION: "3.47.0" JAVA_VERSION: "17" FLUTTER_RUST_BRIDGE_VERSION: "1.80.1" RUST_VERSION: "1.93" From ece5cf54e6cc2d725f0a38c01a0a711ed9bc9daa Mon Sep 17 00:00:00 2001 From: fmar Date: Fri, 28 Aug 2026 13:26:24 +0200 Subject: [PATCH 04/22] bolt12 wallet --- doc/library-development/publish.md | 4 + .../drift/lib/src/drift_cache_manager.dart | 45 +- .../drift/test/drift_cache_manager_test.dart | 40 +- packages/ndk/example/wallets/send.dart | 6 +- .../models/wallet_transaction_model.dart | 1 + .../providers/bolt12/bolt12_wallet.dart | 117 ++++ .../bolt12/bolt12_wallet_provider.dart | 568 +++++++++++++++ .../wallet/providers/nwc/nwc_wallet.dart | 43 +- .../domain_layer/entities/wallet/wallet.dart | 28 + .../entities/wallet/wallet_factory.dart | 11 + .../entities/wallet/wallet_transaction.dart | 1 + .../entities/wallet/wallet_type.dart | 4 +- .../usecases/wallets/wallets.dart | 171 +++++ packages/ndk/lib/entities.dart | 2 + packages/ndk/lib/presentation_layer/init.dart | 4 +- packages/ndk/pubspec.yaml | 2 + .../mem_cache_manager_test.mocks.dart | 659 +++++++++++++----- .../websocket_nostr_transport_test.mocks.dart | 56 +- .../ndk/test/entities/bolt12_wallet_test.dart | 115 +++ .../ndk/test/entities/nwc_wallet_test.dart | 11 + .../test/usecases/lnurl/lnurl_test.mocks.dart | 216 ++++-- .../nip05/nip05_network_test.mocks.dart | 216 ++++-- .../test/usecases/wallets_transfer_test.dart | 285 ++++++++ .../usecases/zaps/zap_receipt_test.mocks.dart | 117 ++-- .../test/usecases/zaps/zaps_test.mocks.dart | 216 ++++-- packages/ndk_flutter/analysis_options.yaml | 8 + packages/ndk_flutter/lib/l10n/app_en.arb | 67 +- .../lib/l10n/app_localizations.dart | 162 +++++ .../lib/l10n/app_localizations_de.dart | 97 ++- .../lib/l10n/app_localizations_en.dart | 97 ++- .../lib/l10n/app_localizations_es.dart | 97 ++- .../lib/l10n/app_localizations_fi.dart | 97 ++- .../lib/l10n/app_localizations_fr.dart | 97 ++- .../lib/l10n/app_localizations_it.dart | 97 ++- .../lib/l10n/app_localizations_ja.dart | 97 ++- .../lib/l10n/app_localizations_pl.dart | 97 ++- .../lib/l10n/app_localizations_pt.dart | 97 ++- .../lib/l10n/app_localizations_ru.dart | 97 ++- .../lib/l10n/app_localizations_sk.dart | 97 ++- .../lib/l10n/app_localizations_zh.dart | 97 ++- .../widgets/wallets/n_add_wallet_dialogs.dart | 237 ++++++- .../lib/widgets/wallets/n_wallet_actions.dart | 5 + .../lib/widgets/wallets/n_wallet_card.dart | 127 +++- .../widgets/wallets/n_wallet_card_list.dart | 25 + .../lib/widgets/wallets/n_wallets.dart | 11 + .../wallets/wallet_action_dialogs.dart | 333 ++++++++- packages/sample-app/analysis_options.yaml | 9 + .../sample-app/lib/bolt12_qr_scanner.dart | 175 +++++ .../generated/sample_app_localizations.dart | 20 +- .../sample_app_localizations_de.dart | 1 - .../sample_app_localizations_en.dart | 1 - .../sample_app_localizations_es.dart | 1 - .../sample_app_localizations_fr.dart | 1 - .../sample_app_localizations_it.dart | 1 - .../sample_app_localizations_ja.dart | 1 - .../sample_app_localizations_pl.dart | 1 - .../sample_app_localizations_ru.dart | 1 - .../sample_app_localizations_zh.dart | 1 - packages/sample-app/lib/wallets.dart | 2 + packages/sample-app/pubspec.lock | 42 +- 60 files changed, 4784 insertions(+), 550 deletions(-) create mode 100644 packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet.dart create mode 100644 packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet_provider.dart create mode 100644 packages/ndk/test/entities/bolt12_wallet_test.dart create mode 100644 packages/ndk/test/usecases/wallets_transfer_test.dart create mode 100644 packages/sample-app/lib/bolt12_qr_scanner.dart diff --git a/doc/library-development/publish.md b/doc/library-development/publish.md index 19b698038..66203f167 100644 --- a/doc/library-development/publish.md +++ b/doc/library-development/publish.md @@ -8,6 +8,10 @@ order: 100 ## publish to github +make sure ndk version.dart is up to date with version from pubspec.yaml, if not run: + +`dart run build_runner build --delete-conflicting-outputs` + Create a tag `git tag -a v1.2.3 -m "Release v1.2.3"` diff --git a/packages/drift/lib/src/drift_cache_manager.dart b/packages/drift/lib/src/drift_cache_manager.dart index 23ceeeffc..006c13b6b 100644 --- a/packages/drift/lib/src/drift_cache_manager.dart +++ b/packages/drift/lib/src/drift_cache_manager.dart @@ -10,12 +10,7 @@ import 'package:ndk/domain_layer/entities/nip_65.dart'; import 'package:ndk/domain_layer/entities/pubkey_mapping.dart'; import 'package:ndk/domain_layer/entities/read_write_marker.dart'; import 'package:ndk/domain_layer/entities/user_relay_list.dart'; -import 'package:ndk/domain_layer/entities/wallet/providers/cashu/cashu_wallet.dart'; -import 'package:ndk/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart'; -import 'package:ndk/domain_layer/entities/wallet/providers/lnurl/lnurl_wallet.dart'; -import 'package:ndk/domain_layer/entities/wallet/wallet.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/wallet/wallet_factory.dart'; import 'package:ndk/domain_layer/repositories/wallets_repo.dart'; import 'package:ndk/ndk.dart'; import 'package:ndk/shared/nips/nip01/event_kind_classification.dart'; @@ -1946,37 +1941,13 @@ class DriftCacheManager extends WalletsRepo implements CacheManager { .map((e) => e.toString()) .toSet(); - switch (type) { - case WalletType.CASHU: - return CashuWallet( - id: row.id, - name: row.name, - supportedUnits: supportedUnits, - mintUrl: metadata['mintUrl'] as String, - mintInfo: CashuMintInfo.fromJson( - metadata['mintInfo'] as Map, - mintUrl: metadata['mintUrl'] as String, - ), - ); - case WalletType.NWC: - return NwcWallet( - id: row.id, - name: row.name, - supportedUnits: supportedUnits, - nwcUrl: metadata['nwcUrl'] as String, - ); - case WalletType.LNURL: - return LnurlWallet( - id: row.id, - name: row.name, - supportedUnits: supportedUnits, - identifier: metadata['identifier'] as String, - lnurlPayUrl: metadata['lnurlPayUrl'] as String, - minSendable: metadata['minSendable'] as int?, - maxSendable: metadata['maxSendable'] as int?, - metadataFetchedAt: metadata['metadataFetchedAt'] as int?, - ); - } + return WalletFactory.fromStorage( + id: row.id, + name: row.name, + type: type, + supportedUnits: supportedUnits, + metadata: metadata, + ); } @override diff --git a/packages/drift/test/drift_cache_manager_test.dart b/packages/drift/test/drift_cache_manager_test.dart index fd5676527..ac7a4931a 100644 --- a/packages/drift/test/drift_cache_manager_test.dart +++ b/packages/drift/test/drift_cache_manager_test.dart @@ -1,10 +1,48 @@ import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:ndk/domain_layer/entities/event_cache_records.dart'; +import 'package:ndk/entities.dart'; import 'package:ndk_drift/ndk_drift.dart'; import 'package:ndk_cache_manager_test_suite/ndk_cache_manager_test_suite.dart'; void main() { + test('persists and restores a BOLT12 wallet', () async { + final db = NdkCacheDatabase.forTesting(NativeDatabase.memory()); + final cacheManager = DriftCacheManager(db); + const offer = + 'lno1pqqq5xj5wajkcan9gdshx6pq23jhxarfdenjqstyv3ex2umnzcss80xkrjkyrjk43u5dgu8f6a450fg2cnjtg7lhg76c3gtk5gdhshns'; + final wallet = Bolt12Wallet( + id: 'bolt12-test', + name: 'BOLT12 test wallet', + supportedUnits: const {'sat'}, + offer: offer, + source: 'alice@example.com', + bip353Address: 'alice@example.com', + description: 'Test offer', + offerId: 'offer-id', + issuer: 'Test issuer', + currency: 'USD', + expiresAt: 2000000000, + quantityMax: 10, + hasBlindedPaths: true, + metadata: const {'cardColor': 123}, + ); + + await cacheManager.storeWallet(wallet); + final restored = await cacheManager.getWallet(wallet.id) as Bolt12Wallet; + + expect(restored.offer, offer); + expect(restored.bip353Address, wallet.bip353Address); + expect(restored.description, wallet.description); + expect(restored.issuer, wallet.issuer); + expect(restored.currency, wallet.currency); + expect(restored.expiresAt, wallet.expiresAt); + expect(restored.quantityMax, wallet.quantityMax); + expect(restored.hasBlindedPaths, isTrue); + expect(restored.metadata['cardColor'], 123); + + await cacheManager.close(); + }); + test('persists full event delivery record state', () async { final db = NdkCacheDatabase.forTesting(NativeDatabase.memory()); final cacheManager = DriftCacheManager(db); diff --git a/packages/ndk/example/wallets/send.dart b/packages/ndk/example/wallets/send.dart index 519555513..87c666f7e 100644 --- a/packages/ndk/example/wallets/send.dart +++ b/packages/ndk/example/wallets/send.dart @@ -7,7 +7,8 @@ import 'package:ndk/domain_layer/entities/cashu/cashu_user_seedphrase.dart'; import 'package:ndk/ndk.dart'; Future main() async { - final invoice = Platform.environment['INVOICE']!; + final payment = Platform.environment['PAYMENT']!; + final amountSats = int.parse(Platform.environment['AMOUNT'] ?? '1000'); final ndk = Ndk( NdkConfig( @@ -29,10 +30,11 @@ Future main() async { final walletId = Platform.environment['WALLET_ID'] ?? wallets.first.id; - final result = await ndk.wallets.send(walletId: walletId, invoice: invoice); + final result = await ndk.wallets.payBip321(walletId: walletId, payment: payment, amountMsat: amountSats * 1000); print('Payment result:'); print('- preimage: ${result.preimage}'); + print('- payerProof: ${result.payerProof}'); print('- fees paid: ${result.feesPaid / 1000} sats'); if (result.errorCode != null || result.errorMessage != null) { print('- error code: ${result.errorCode}'); diff --git a/packages/ndk/lib/data_layer/models/wallet_transaction_model.dart b/packages/ndk/lib/data_layer/models/wallet_transaction_model.dart index 9dfa27605..51922efbc 100644 --- a/packages/ndk/lib/data_layer/models/wallet_transaction_model.dart +++ b/packages/ndk/lib/data_layer/models/wallet_transaction_model.dart @@ -19,6 +19,7 @@ class WalletTransactionModel { case WalletType.NWC: return NwcWalletTransactionModel.fromJson(json); case WalletType.LNURL: + case WalletType.BOLT12: return LnurlWalletTransactionModel.fromJson(json); } } diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet.dart new file mode 100644 index 000000000..2d19ff30b --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet.dart @@ -0,0 +1,117 @@ +import '../../wallet.dart'; +import '../../wallet_type.dart'; + +/// A receive-only wallet backed by a reusable BOLT12 offer. +class Bolt12Wallet extends Wallet { + /// Canonical, lowercase `lno...` offer. + final String offer; + + /// The value originally entered or scanned by the user. + final String source; + + /// BIP353 address used to resolve [offer], when applicable. + final String? bip353Address; + + final String? description; + final String? nodeId; + final String? offerId; + final String? amount; + final String? issuer; + final String? currency; + final int? expiresAt; + final int? quantityMax; + final bool hasBlindedPaths; + + Bolt12Wallet({ + required super.id, + required super.name, + super.type = WalletType.BOLT12, + required super.supportedUnits, + required this.offer, + required this.source, + this.bip353Address, + this.description, + this.nodeId, + this.offerId, + this.amount, + this.issuer, + this.currency, + this.expiresAt, + this.quantityMax, + this.hasBlindedPaths = false, + Map? metadata, + }) : super( + metadata: Map.unmodifiable({ + ...(metadata ?? const {}), + 'offer': offer, + 'source': source, + 'bip353Address': bip353Address, + 'description': description, + 'nodeId': nodeId, + 'offerId': offerId, + 'amount': amount, + 'issuer': issuer, + 'currency': currency, + 'expiresAt': expiresAt, + 'quantityMax': quantityMax, + 'hasBlindedPaths': hasBlindedPaths, + }), + ); + + @override + Map toMetadata() => metadata; + + static Bolt12Wallet fromStorage({ + required String id, + required String name, + required Set supportedUnits, + required Map metadata, + }) { + final offer = metadata['offer'] as String?; + if (offer == null || offer.isEmpty) { + throw ArgumentError('Bolt12Wallet storage requires metadata["offer"]'); + } + + return Bolt12Wallet( + id: id, + name: name, + supportedUnits: supportedUnits, + offer: offer, + source: metadata['source'] as String? ?? offer, + bip353Address: metadata['bip353Address'] as String?, + description: metadata['description'] as String?, + nodeId: metadata['nodeId'] as String?, + offerId: metadata['offerId'] as String?, + amount: metadata['amount']?.toString(), + issuer: metadata['issuer'] as String?, + currency: metadata['currency'] as String?, + expiresAt: _readInt(metadata['expiresAt']), + quantityMax: _readInt(metadata['quantityMax']), + hasBlindedPaths: metadata['hasBlindedPaths'] == true, + metadata: metadata, + ); + } + + @override + bool get canReceive => true; + + @override + bool get canSend => false; + + @override + Set get receivePaymentProtocols => const { + WalletPaymentProtocol.bolt12, + }; + + @override + bool get supportsBip321Receive => true; + + @override + bool get supportsBolt11InvoiceReceive => false; + + static int? _readInt(Object? value) { + if (value is int) return value; + if (value is num) return value.toInt(); + return int.tryParse(value?.toString() ?? ''); + } +} diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet_provider.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet_provider.dart new file mode 100644 index 000000000..737d167e5 --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet_provider.dart @@ -0,0 +1,568 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:dart_bip353/dart_bip353.dart'; +import 'package:dart_bolt12_decoder/dart_bolt12_decoder.dart'; + +import 'package:ndk/domain_layer/usecases/nwc/responses/pay_invoice_response.dart'; +import 'package:ndk/domain_layer/usecases/nwc/responses/pay_response.dart'; +import 'package:ndk/domain_layer/usecases/nwc/responses/receive_response.dart'; +import '../../wallet.dart'; +import '../../wallet_balance.dart'; +import '../../wallet_provider.dart'; +import '../../wallet_transaction.dart'; +import '../../wallet_type.dart'; +import 'bolt12_wallet.dart'; + +typedef Bip353OfferResolver = Future Function(String address); + +class Bolt12ResolvedOffer { + final String offer; + final String source; + final String? bip353Address; + final Map decoded; + + const Bolt12ResolvedOffer({ + required this.offer, + required this.source, + required this.decoded, + this.bip353Address, + }); + + Map toMetadata() => { + 'offer': offer, + 'source': source, + 'bip353Address': bip353Address, + 'description': _nonEmptyString(decoded['offer_description']), + 'nodeId': _nonEmptyString(decoded['offer_node_id']), + 'offerId': _nonEmptyString(decoded['offer_id']), + 'amount': _nonEmptyString(decoded['offer_amount']), + 'issuer': _nonEmptyString(decoded['offer_issuer']), + 'currency': _nonEmptyString(decoded['offer_currency']), + 'expiresAt': _intValue(decoded['offer_absolute_expiry']), + 'quantityMax': _intValue(decoded['offer_quantity_max']), + 'hasBlindedPaths': decoded['has_blinded_paths'] == true || + _hasValues(decoded['offer_paths']), + }; + + static String? _nonEmptyString(Object? value) { + final normalized = value?.toString().trim(); + return normalized == null || normalized.isEmpty ? null : normalized; + } + + static int? _intValue(Object? value) { + if (value is int) return value; + if (value is num) return value.toInt(); + return int.tryParse(value?.toString() ?? ''); + } + + static bool _hasValues(Object? value) { + if (value is Iterable) return value.isNotEmpty; + return value != null; + } +} + +/// Provider for receive-only BOLT12 offer wallets. +class Bolt12WalletProvider implements WalletProvider { + const Bolt12WalletProvider(); + + @override + WalletType get type => WalletType.BOLT12; + + /// Whether [input] has the shape of a supported BOLT12 input. + /// + /// Full offer validation and BIP353 DNS resolution happen in [resolveInput]. + static bool isSupportedInput(String input) { + final value = input.trim(); + if (value.isEmpty) return false; + if (_directOffer(value) != null) return true; + return _bip353Address(value) != null; + } + + /// Resolves a direct offer, a BIP321 `bitcoin:?lno=...` URI, or a BIP353 + /// address into a validated canonical BOLT12 offer. + static Future resolveInput( + String input, { + Bip353OfferResolver? bip353Resolver, + }) async { + final source = input.trim(); + if (source.isEmpty) { + throw const FormatException('BOLT12 offer or BIP353 address is required'); + } + + final direct = _directOffer(source); + if (direct != null) { + return _validate(offer: direct, source: source); + } + + final address = _bip353Address(source); + if (address == null) { + throw const FormatException( + 'Expected an lno offer, bitcoin:?lno=... URI, or BIP353 address', + ); + } + + final resolver = bip353Resolver ?? _resolveBip353; + final resolvedOffer = await resolver(address); + if (resolvedOffer == null || resolvedOffer.trim().isEmpty) { + throw FormatException( + 'BIP353 address $address does not publish a BOLT12 offer', + ); + } + + return _validate( + offer: resolvedOffer, + source: source, + bip353Address: address, + ); + } + + static Future _resolveBip353(String address) async { + final response = await Bip353.getAdressResolve(address); + return response.offer; + } + + static Bolt12ResolvedOffer _validate({ + required String offer, + required String source, + String? bip353Address, + }) { + final envelope = _Bolt12OfferEnvelope.parse(offer); + final canonicalOffer = envelope.canonicalOffer; + + // dart_bolt12_decoder 0.8.0 predates the current blinded-path encoding + // and also requires description + issuer id, which modern offers may omit. + // Use it for the offer shapes it understands and retain strict structural + // validation for current-spec offers. + Map decoded = envelope.details; + if (envelope.isSupportedByDetailDecoder) { + final packageDecoded = Bolt12Decoder.decode(canonicalOffer); + if (packageDecoded != null && + packageDecoded['type'] == 'offer' && + packageDecoded['valid'] == true) { + decoded = {...decoded, ...packageDecoded}; + } + } + + return Bolt12ResolvedOffer( + offer: canonicalOffer, + source: source, + bip353Address: bip353Address, + decoded: Map.unmodifiable(decoded), + ); + } + + static String? _directOffer(String input) { + final value = input.trim(); + if (value.toLowerCase().startsWith('lno1')) return value; + + Uri uri; + try { + final normalizedValue = value.toLowerCase().startsWith('bitcoin?') + ? 'bitcoin:${value.substring('bitcoin'.length)}' + : value; + uri = Uri.parse(normalizedValue); + } on FormatException { + return null; + } + if (uri.scheme.toLowerCase() != 'bitcoin') return null; + + for (final entry in uri.queryParameters.entries) { + if (entry.key.toLowerCase() == 'lno' && entry.value.isNotEmpty) { + return entry.value; + } + } + return null; + } + + static String? _bip353Address(String input) { + var value = input.trim(); + if (value.startsWith('₿')) value = value.substring(1); + final parts = value.split('@'); + if (parts.length != 2 || + parts[0].isEmpty || + parts[1].isEmpty || + value.contains(RegExp(r'\s'))) { + return null; + } + return value; + } + + @override + Wallet createWallet({ + required String id, + required String name, + required Set supportedUnits, + required Map metadata, + }) { + final offer = metadata['offer'] as String?; + if (offer == null || offer.isEmpty) { + throw ArgumentError( + 'Bolt12Wallet requires resolved metadata from resolveInput()', + ); + } + + final validated = _validate( + offer: offer, + source: metadata['source'] as String? ?? offer, + bip353Address: metadata['bip353Address'] as String?, + ); + final resolvedMetadata = { + ...metadata, + ...validated.toMetadata(), + }; + + return Bolt12Wallet( + id: id, + name: name, + supportedUnits: supportedUnits, + offer: validated.offer, + source: validated.source, + bip353Address: validated.bip353Address, + description: resolvedMetadata['description'] as String?, + nodeId: resolvedMetadata['nodeId'] as String?, + offerId: resolvedMetadata['offerId'] as String?, + amount: resolvedMetadata['amount']?.toString(), + issuer: resolvedMetadata['issuer'] as String?, + currency: resolvedMetadata['currency'] as String?, + expiresAt: Bolt12ResolvedOffer._intValue( + resolvedMetadata['expiresAt'], + ), + quantityMax: Bolt12ResolvedOffer._intValue( + resolvedMetadata['quantityMax'], + ), + hasBlindedPaths: resolvedMetadata['hasBlindedPaths'] == true, + metadata: resolvedMetadata, + ); + } + + @override + Future initialize(Wallet wallet) async { + final bolt12Wallet = wallet as Bolt12Wallet; + _validate(offer: bolt12Wallet.offer, source: bolt12Wallet.source); + return null; + } + + @override + Future removeWallet(Wallet wallet) async {} + + @override + Stream> getBalances(Wallet wallet) => Stream.value([]); + + @override + Stream> getPendingTransactions(Wallet wallet) => + Stream.value([]); + + @override + Stream> getRecentTransactions(Wallet wallet) => + Stream.value([]); + + @override + Future send( + Wallet wallet, + String invoice, { + Duration? timeout, + }) { + throw UnsupportedError( + 'BOLT12 wallet is receive-only and cannot pay invoices', + ); + } + + @override + Future receive(Wallet wallet, int amountSats) async => + (wallet as Bolt12Wallet).offer; + + @override + Future payBip321( + Wallet wallet, { + required String payment, + int? amountMsat, + String? payerNote, + Map? metadata, + Duration? timeout, + }) { + throw UnsupportedError( + 'BOLT12 wallet is receive-only and cannot pay BIP-321 instructions', + ); + } + + @override + Future receiveBip321( + Wallet wallet, { + int? amountMsat, + String? description, + Map? metadata, + Duration? timeout, + }) async { + if (description?.isNotEmpty == true) { + throw UnsupportedError( + 'A BOLT12 offer controls its own payment description', + ); + } + final offer = (wallet as Bolt12Wallet).offer; + return ReceiveResponse( + resultType: 'receive', + bip321: Uri( + scheme: 'bitcoin', + queryParameters: {'lno': offer}, + ).toString(), + ); + } + + @override + Stream> get discoveredWallets => Stream.value([]); +} + +class _Bolt12OfferEnvelope { + static const _alphabet = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; + static const _knownOfferTypes = {2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22}; + + final String canonicalOffer; + final Map> fields; + final Map details; + + const _Bolt12OfferEnvelope({ + required this.canonicalOffer, + required this.fields, + required this.details, + }); + + bool get isSupportedByDetailDecoder => + !fields.containsKey(16) && + fields.containsKey(10) && + fields.containsKey(22); + + static _Bolt12OfferEnvelope parse(String input) { + final withoutContinuations = input.trim().replaceAll( + RegExp(r'\+\s*'), + '', + ); + final letters = withoutContinuations.replaceAll(RegExp('[^A-Za-z]'), ''); + if (letters != letters.toLowerCase() && letters != letters.toUpperCase()) { + throw const FormatException( + 'BOLT12 strings must not mix uppercase and lowercase', + ); + } + + final canonical = withoutContinuations.toLowerCase(); + if (!canonical.startsWith('lno1')) { + throw const FormatException('BOLT12 offers must start with lno1'); + } + final encoded = canonical.substring(4); + if (encoded.isEmpty) { + throw const FormatException('BOLT12 offer has no encoded data'); + } + + final values = []; + for (final codeUnit in encoded.codeUnits) { + final value = _alphabet.indexOf(String.fromCharCode(codeUnit)); + if (value < 0) { + throw const FormatException('Invalid character in BOLT12 offer'); + } + values.add(value); + } + + final bytes = _convertFiveToEightBits(values); + final fields = >{}; + var offset = 0; + var previousType = -1; + while (offset < bytes.length) { + final typeResult = _readBigSize(bytes, offset); + final type = typeResult.value; + offset = typeResult.nextOffset; + final lengthResult = _readBigSize(bytes, offset); + final length = lengthResult.value; + offset = lengthResult.nextOffset; + + if (type <= previousType) { + throw const FormatException( + 'BOLT12 TLV fields must be unique and ordered', + ); + } + if (!((type >= 1 && type <= 79) || + (type >= 1000000000 && type <= 1999999999))) { + throw FormatException('Invalid BOLT12 offer field type $type'); + } + if (type <= 79 && type.isEven && !_knownOfferTypes.contains(type)) { + throw FormatException('Unknown required BOLT12 offer field $type'); + } + if (length < 0 || length > bytes.length - offset) { + throw const FormatException('Truncated BOLT12 offer field'); + } + + fields[type] = bytes.sublist(offset, offset + length); + offset += length; + previousType = type; + } + + _validateOfferFields(fields); + return _Bolt12OfferEnvelope( + canonicalOffer: canonical, + fields: Map.unmodifiable(fields), + details: Map.unmodifiable(_basicDetails(fields)), + ); + } + + static void _validateOfferFields(Map> fields) { + final paths = fields[16]; + final issuerId = fields[22]; + if ((paths == null || paths.isEmpty) && issuerId == null) { + throw const FormatException( + 'BOLT12 offer requires offer_paths or offer_issuer_id', + ); + } + if (issuerId != null && issuerId.length != 33) { + throw const FormatException('Invalid BOLT12 offer_issuer_id'); + } + + final chains = fields[2]; + if (chains != null && (chains.isEmpty || chains.length % 32 != 0)) { + throw const FormatException('Invalid BOLT12 offer_chains'); + } + final currency = fields[6]; + if (currency != null && currency.length != 3) { + throw const FormatException('Invalid BOLT12 offer_currency'); + } + + final amountBytes = fields[8]; + if (amountBytes != null) { + _readTu64(amountBytes); + if (!fields.containsKey(10)) { + throw const FormatException( + 'BOLT12 offer_amount requires offer_description', + ); + } + } else if (currency != null) { + throw const FormatException( + 'BOLT12 offer_currency requires offer_amount', + ); + } + + final expiryBytes = fields[14]; + if (expiryBytes != null) { + final expiry = _readTu64(expiryBytes); + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + if (expiry < now) { + throw const FormatException('BOLT12 offer has expired'); + } + } + } + + static Map _basicDetails(Map> fields) { + final details = {'type': 'offer', 'valid': true}; + final currency = fields[6]; + if (currency != null) { + try { + details['offer_currency'] = utf8.decode(currency); + } on FormatException { + throw const FormatException('Invalid UTF-8 in BOLT12 currency'); + } + } + final amount = fields[8]; + if (amount != null) details['offer_amount'] = _readTu64(amount).toString(); + final description = fields[10]; + if (description != null) { + try { + details['offer_description'] = utf8.decode(description); + } on FormatException { + throw const FormatException('Invalid UTF-8 in BOLT12 description'); + } + } + final issuer = fields[18]; + if (issuer != null) { + try { + details['offer_issuer'] = utf8.decode(issuer); + } on FormatException { + throw const FormatException('Invalid UTF-8 in BOLT12 issuer'); + } + } + final expiry = fields[14]; + if (expiry != null) { + details['offer_absolute_expiry'] = _readTu64(expiry); + } + final paths = fields[16]; + if (paths != null && paths.isNotEmpty) { + details['has_blinded_paths'] = true; + } + final quantityMax = fields[20]; + if (quantityMax != null) { + details['offer_quantity_max'] = _readTu64(quantityMax); + } + final issuerId = fields[22]; + if (issuerId != null) { + details['offer_node_id'] = + issuerId.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); + } + return details; + } + + static List _convertFiveToEightBits(List values) { + const maxAccumulator = (1 << 12) - 1; + var accumulator = 0; + var bits = 0; + final result = []; + for (final value in values) { + accumulator = ((accumulator << 5) | value) & maxAccumulator; + bits += 5; + while (bits >= 8) { + bits -= 8; + result.add((accumulator >> bits) & 0xff); + } + } + if (bits >= 5 || ((accumulator << (8 - bits)) & 0xff) != 0) { + throw const FormatException('Invalid BOLT12 data padding'); + } + return result; + } + + static _BigSizeResult _readBigSize(List bytes, int offset) { + if (offset >= bytes.length) { + throw const FormatException('Truncated BOLT12 bigsize'); + } + final first = bytes[offset++]; + if (first < 0xfd) return _BigSizeResult(first, offset); + + final byteCount = first == 0xfd + ? 2 + : first == 0xfe + ? 4 + : 8; + if (offset + byteCount > bytes.length) { + throw const FormatException('Truncated BOLT12 bigsize'); + } + var value = 0; + for (var index = 0; index < byteCount; index++) { + value = value * 256 + bytes[offset + index]; + if (value > 1999999999 && byteCount == 8) { + throw const FormatException('BOLT12 bigsize is too large'); + } + } + final minimum = byteCount == 2 + ? 0xfd + : byteCount == 4 + ? 0x10000 + : 0x100000000; + if (value < minimum) { + throw const FormatException('Non-canonical BOLT12 bigsize'); + } + return _BigSizeResult(value, offset + byteCount); + } + + static int _readTu64(List bytes) { + if (bytes.length > 8 || (bytes.isNotEmpty && bytes.first == 0)) { + throw const FormatException('Invalid BOLT12 truncated integer'); + } + var value = 0; + for (final byte in bytes) { + value = value * 256 + byte; + } + return value; + } +} + +class _BigSizeResult { + final int value; + final int nextOffset; + + const _BigSizeResult(this.value, this.nextOffset); +} diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart index e623819d3..05f37e16f 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart @@ -94,18 +94,51 @@ class NwcWallet extends Wallet { return {}; } - Set get _effectivePermissions => + /// Permissions advertised by the live connection, falling back to the + /// persisted capability snapshot while the connection initializes. + Set get effectivePermissions => connection?.permissions.isNotEmpty == true ? connection!.permissions : cachedPermissions; + bool supportsMethod(NwcMethod method) => + effectivePermissions.contains(method.name); + @override bool get canReceive => - _effectivePermissions.contains(NwcMethod.MAKE_INVOICE.name) || - _effectivePermissions.contains(NwcMethod.RECEIVE.name); + supportsMethod(NwcMethod.MAKE_INVOICE) || + supportsMethod(NwcMethod.RECEIVE); @override bool get canSend => - _effectivePermissions.contains(NwcMethod.PAY_INVOICE.name) || - _effectivePermissions.contains(NwcMethod.PAY.name); + supportsMethod(NwcMethod.PAY_INVOICE) || supportsMethod(NwcMethod.PAY); + + @override + Set get sendPaymentProtocols => { + if (supportsMethod(NwcMethod.PAY_INVOICE) || + supportsMethod(NwcMethod.PAY)) + WalletPaymentProtocol.bolt11, + if (supportsMethod(NwcMethod.PAY)) WalletPaymentProtocol.bolt12, + }; + + @override + Set get receivePaymentProtocols => { + if (supportsMethod(NwcMethod.MAKE_INVOICE) || + supportsMethod(NwcMethod.RECEIVE)) + WalletPaymentProtocol.bolt11, + if (supportsMethod(NwcMethod.RECEIVE)) WalletPaymentProtocol.bolt12, + }; + + @override + bool get supportsBip321Pay => supportsMethod(NwcMethod.PAY); + + @override + bool get supportsBip321Receive => supportsMethod(NwcMethod.RECEIVE); + + @override + bool get supportsBolt11InvoicePay => supportsMethod(NwcMethod.PAY_INVOICE); + + @override + bool get supportsBolt11InvoiceReceive => + supportsMethod(NwcMethod.MAKE_INVOICE); } diff --git a/packages/ndk/lib/domain_layer/entities/wallet/wallet.dart b/packages/ndk/lib/domain_layer/entities/wallet/wallet.dart index 937629ece..1e3b28106 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/wallet.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/wallet.dart @@ -1,5 +1,8 @@ import 'wallet_type.dart'; +/// Lightning payment protocols a wallet can use for internal transfers. +enum WalletPaymentProtocol { bolt11, bolt12 } + /// Base interface for all wallet types /// Provides common properties and methods that all wallets must implement abstract class Wallet { @@ -44,4 +47,29 @@ abstract class Wallet { /// Indicates if the wallet can send funds bool get canSend; + + /// Payment protocols this wallet can send. + /// + /// Wallets keep BOLT11 as the compatibility default. Wallet types with + /// richer or dynamic capabilities should override this getter. + Set get sendPaymentProtocols => canSend + ? const {WalletPaymentProtocol.bolt11} + : const {}; + + /// Payment protocols this wallet can receive. + Set get receivePaymentProtocols => canReceive + ? const {WalletPaymentProtocol.bolt11} + : const {}; + + /// Whether this wallet can use the NWC-321/BIP-321 `pay` operation. + bool get supportsBip321Pay => false; + + /// Whether this wallet can use the NWC-321/BIP-321 `receive` operation. + bool get supportsBip321Receive => false; + + /// Whether this wallet can directly pay a BOLT11 invoice. + bool get supportsBolt11InvoicePay => canSend; + + /// Whether this wallet can directly create a BOLT11 invoice. + bool get supportsBolt11InvoiceReceive => canReceive; } diff --git a/packages/ndk/lib/domain_layer/entities/wallet/wallet_factory.dart b/packages/ndk/lib/domain_layer/entities/wallet/wallet_factory.dart index 921c86af1..c58164e50 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/wallet_factory.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/wallet_factory.dart @@ -17,10 +17,14 @@ export 'providers/nwc/nwc_wallet_provider.dart'; export 'providers/lnurl/lnurl_wallet.dart'; export 'providers/lnurl/lnurl_wallet_provider.dart'; +export 'providers/bolt12/bolt12_wallet.dart'; +export 'providers/bolt12/bolt12_wallet_provider.dart'; + // Then: imports needed for WalletFactory import 'providers/cashu/cashu_wallet.dart'; import 'providers/nwc/nwc_wallet.dart'; import 'providers/lnurl/lnurl_wallet.dart'; +import 'providers/bolt12/bolt12_wallet.dart'; import 'wallet.dart'; import 'wallet_type.dart'; @@ -59,6 +63,13 @@ class WalletFactory { supportedUnits: supportedUnits, metadata: metadata, ); + case WalletType.BOLT12: + return Bolt12Wallet.fromStorage( + id: id, + name: name, + supportedUnits: supportedUnits, + metadata: metadata, + ); } } } diff --git a/packages/ndk/lib/domain_layer/entities/wallet/wallet_transaction.dart b/packages/ndk/lib/domain_layer/entities/wallet/wallet_transaction.dart index 201526423..cb0085c15 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/wallet_transaction.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/wallet_transaction.dart @@ -101,6 +101,7 @@ abstract class WalletTransaction { initiatedDate: initiatedDate, ); case WalletType.LNURL: + case WalletType.BOLT12: return LnurlWalletTransaction( id: id, walletId: walletId, diff --git a/packages/ndk/lib/domain_layer/entities/wallet/wallet_type.dart b/packages/ndk/lib/domain_layer/entities/wallet/wallet_type.dart index 3413275c0..512a2f7a5 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/wallet_type.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/wallet_type.dart @@ -4,7 +4,9 @@ enum WalletType { // ignore: constant_identifier_names CASHU('cashu'), // ignore: constant_identifier_names - LNURL('lnurl'); + LNURL('lnurl'), + // ignore: constant_identifier_names + BOLT12('bolt12'); final String value; diff --git a/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart b/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart index 8df0c0659..9ad956c36 100644 --- a/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart +++ b/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart @@ -5,6 +5,7 @@ import 'package:rxdart/rxdart.dart'; import '../../entities/wallet/wallet.dart'; import '../../entities/wallet/wallet_balance.dart'; +import '../../entities/wallet/bip321.dart'; import '../../entities/wallet/wallet_provider.dart'; import '../../entities/wallet/wallet_transaction.dart'; import '../../entities/wallet/wallet_type.dart'; @@ -633,6 +634,155 @@ class Wallets { ); } + /// Returns the protocol that can transfer funds from [source] to + /// [destination], or null when the wallets have no compatible payment path. + WalletPaymentProtocol? compatibleTransferProtocol({ + required Wallet source, + required Wallet destination, + }) { + if (source.id == destination.id || + !source.canSend || + !destination.canReceive || + !source.supportedUnits.contains('sat') || + !destination.supportedUnits.contains('sat')) { + return null; + } + + final common = source.sendPaymentProtocols.intersection( + destination.receivePaymentProtocols, + ); + + // A BOLT12-only destination must use the reusable offer through BIP-321. + if (destination.receivePaymentProtocols.length == 1 && + destination.receivePaymentProtocols.contains( + WalletPaymentProtocol.bolt12, + ) && + common.contains(WalletPaymentProtocol.bolt12) && + source.supportsBip321Pay && + destination.supportsBip321Receive) { + return WalletPaymentProtocol.bolt12; + } + + if (common.contains(WalletPaymentProtocol.bolt11)) { + final genericPath = source.supportsBip321Pay && + (destination.supportsBip321Receive || + destination.supportsBolt11InvoiceReceive); + final invoicePath = source.supportsBolt11InvoicePay && + destination.supportsBolt11InvoiceReceive; + if (genericPath || invoicePath) return WalletPaymentProtocol.bolt11; + } + + if (common.contains(WalletPaymentProtocol.bolt12) && + source.supportsBip321Pay && + destination.supportsBip321Receive) { + return WalletPaymentProtocol.bolt12; + } + return null; + } + + /// Transfers funds directly between two configured wallets. + /// + /// BOLT11-capable wallets exchange a fresh invoice. A BOLT12-only receiver + /// exposes its reusable offer and requires a BIP-321-capable sender. + Future transfer({ + required String sourceWalletId, + required String destinationWalletId, + int? amountMsat, + Duration? timeout, + }) async { + await _initializationFuture; + final source = await _getWalletForOperation(sourceWalletId); + final destination = await _getWalletForOperation(destinationWalletId); + final protocol = compatibleTransferProtocol( + source: source, + destination: destination, + ); + if (protocol == null) { + throw UnsupportedError( + 'The selected wallets have no compatible payment protocol', + ); + } + if (amountMsat != null && amountMsat <= 0) { + throw ArgumentError.value( + amountMsat, + 'amountMsat', + 'Transfer amount must be positive', + ); + } + if (protocol == WalletPaymentProtocol.bolt11 && + (amountMsat == null || amountMsat % 1000 != 0)) { + throw ArgumentError.value( + amountMsat, + 'amountMsat', + 'BOLT11 wallet transfers require a positive whole-satoshi amount', + ); + } + + ReceiveResponse? receiveResponse; + late final String payment; + if (destination.supportsBip321Receive) { + receiveResponse = await receiveBip321( + walletId: destination.id, + amountMsat: amountMsat, + timeout: timeout, + ); + payment = receiveResponse.bip321; + } else { + final invoice = await receive( + walletId: destination.id, + amountSats: amountMsat! ~/ 1000, + ); + payment = Bip321.fromBolt11(invoice); + } + + if (source.supportsBip321Pay) { + final payResponse = await payBip321( + walletId: source.id, + payment: payment, + amountMsat: amountMsat, + timeout: timeout, + ); + if (payResponse.errorCode != null || payResponse.state == 'failed') { + throw StateError( + payResponse.errorMessage ?? + payResponse.failureReason ?? + 'Wallet transfer failed', + ); + } + final selectedProtocol = payResponse.instructionType == 'bolt12' + ? WalletPaymentProtocol.bolt12 + : WalletPaymentProtocol.bolt11; + return WalletTransferResult( + sourceWalletId: source.id, + destinationWalletId: destination.id, + protocol: selectedProtocol, + payment: payment, + receiveResponse: receiveResponse, + payResponse: payResponse, + ); + } + + final invoice = Bip321.getBolt11(payment); + final payInvoiceResponse = await send( + walletId: source.id, + invoice: invoice, + timeout: timeout, + ); + if (payInvoiceResponse.errorCode != null) { + throw StateError( + payInvoiceResponse.errorMessage ?? 'Wallet transfer failed', + ); + } + return WalletTransferResult( + sourceWalletId: source.id, + destinationWalletId: destination.id, + protocol: WalletPaymentProtocol.bolt11, + payment: payment, + receiveResponse: receiveResponse, + payInvoiceResponse: payInvoiceResponse, + ); + } + Future _getWalletForOperation(String walletId) async { final inMemory = _wallets.firstWhereOrNull( (wallet) => wallet.id == walletId, @@ -723,3 +873,24 @@ class Wallets { _walletsSubject.add(wallets); } } + +/// Result of a completed or submitted wallet-to-wallet transfer. +class WalletTransferResult { + final String sourceWalletId; + final String destinationWalletId; + final WalletPaymentProtocol protocol; + final String payment; + final ReceiveResponse? receiveResponse; + final PayResponse? payResponse; + final PayInvoiceResponse? payInvoiceResponse; + + const WalletTransferResult({ + required this.sourceWalletId, + required this.destinationWalletId, + required this.protocol, + required this.payment, + this.receiveResponse, + this.payResponse, + this.payInvoiceResponse, + }); +} diff --git a/packages/ndk/lib/entities.dart b/packages/ndk/lib/entities.dart index 73a7cf3e9..ed3a16678 100644 --- a/packages/ndk/lib/entities.dart +++ b/packages/ndk/lib/entities.dart @@ -62,6 +62,8 @@ export 'domain_layer/entities/wallet/wallet_factory.dart'; export 'domain_layer/entities/wallet/providers/cashu/cashu_wallet.dart'; export 'domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart'; export 'domain_layer/entities/wallet/providers/lnurl/lnurl_wallet.dart'; +export 'domain_layer/entities/wallet/providers/bolt12/bolt12_wallet.dart'; +export 'domain_layer/entities/wallet/providers/bolt12/bolt12_wallet_provider.dart'; // testing export 'domain_layer/usecases/wallets/wallets.dart'; diff --git a/packages/ndk/lib/presentation_layer/init.dart b/packages/ndk/lib/presentation_layer/init.dart index e47423777..60705b675 100644 --- a/packages/ndk/lib/presentation_layer/init.dart +++ b/packages/ndk/lib/presentation_layer/init.dart @@ -19,6 +19,7 @@ import '../domain_layer/entities/relay_connectivity.dart'; import '../domain_layer/entities/wallet/providers/cashu/cashu_wallet_provider.dart'; import '../domain_layer/entities/wallet/providers/nwc/nwc_wallet_provider.dart'; import '../domain_layer/entities/wallet/providers/lnurl/lnurl_wallet_provider.dart'; +import '../domain_layer/entities/wallet/providers/bolt12/bolt12_wallet_provider.dart'; import '../domain_layer/repositories/blossom.dart'; import '../domain_layer/repositories/cashu_repo.dart'; import '../domain_layer/repositories/lnurl_transport.dart'; @@ -313,6 +314,7 @@ class Initialization { // Create LNURL wallet provider after lnurl is initialized final lnurlProvider = LnurlWalletProvider(lnurl); + const bolt12Provider = Bolt12WalletProvider(); zaps = Zaps(requests: requests, nwc: nwc, lnurl: lnurl); @@ -358,7 +360,7 @@ class Initialization { connectivity = Connectivy(relayManager); wallets = Wallets( - providers: [cashuProvider, nwcProvider, lnurlProvider], + providers: [cashuProvider, nwcProvider, lnurlProvider, bolt12Provider], repository: _ndkConfig.walletsRepo!, ); proofOfWork = ProofOfWork(); diff --git a/packages/ndk/pubspec.yaml b/packages/ndk/pubspec.yaml index 3b3adfe76..5ba7eb8ed 100644 --- a/packages/ndk/pubspec.yaml +++ b/packages/ndk/pubspec.yaml @@ -24,6 +24,8 @@ platforms: windows: dependencies: + dart_bip353: ^0.8.0 + dart_bolt12_decoder: ^0.8.0 ndk_bip32_keys: ^0.1.0-dev.0+1 http: ^1.6.0 bip340: ">=0.3.0 <0.4.0" diff --git a/packages/ndk/test/data_layer/cache_manager/mem_cache_manager_test.mocks.dart b/packages/ndk/test/data_layer/cache_manager/mem_cache_manager_test.mocks.dart index a9c095688..dbd5b18a6 100644 --- a/packages/ndk/test/data_layer/cache_manager/mem_cache_manager_test.mocks.dart +++ b/packages/ndk/test/data_layer/cache_manager/mem_cache_manager_test.mocks.dart @@ -34,23 +34,43 @@ import 'package:ndk/domain_layer/entities/user_relay_list.dart' as _i6; // ignore_for_file: invalid_use_of_internal_member class _FakeNip65_0 extends _i1.SmartFake implements _i2.Nip65 { - _FakeNip65_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeNip65_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } class _FakeNip01Event_1 extends _i1.SmartFake implements _i3.Nip01Event { - _FakeNip01Event_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeNip01Event_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } class _FakeMetadata_2 extends _i1.SmartFake implements _i4.Metadata { - _FakeMetadata_2(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeMetadata_2( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } class _FakeNip05_3 extends _i1.SmartFake implements _i5.Nip05 { - _FakeNip05_3(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeNip05_3( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } /// A class which mocks [UserRelayList]. @@ -64,13 +84,17 @@ class MockUserRelayList extends _i1.Mock implements _i6.UserRelayList { @override String get pubKey => (super.noSuchMethod( Invocation.getter(#pubKey), - returnValue: _i7.dummyValue(this, Invocation.getter(#pubKey)), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#pubKey), + ), ) as String); @override - int get createdAt => - (super.noSuchMethod(Invocation.getter(#createdAt), returnValue: 0) - as int); + int get createdAt => (super.noSuchMethod( + Invocation.getter(#createdAt), + returnValue: 0, + ) as int); @override int get refreshedTimestamp => (super.noSuchMethod( @@ -85,43 +109,72 @@ class MockUserRelayList extends _i1.Mock implements _i6.UserRelayList { ) as Map); @override - Iterable get urls => - (super.noSuchMethod(Invocation.getter(#urls), returnValue: []) - as Iterable); + Iterable get urls => (super.noSuchMethod( + Invocation.getter(#urls), + returnValue: [], + ) as Iterable); @override - Iterable get readUrls => - (super.noSuchMethod(Invocation.getter(#readUrls), returnValue: []) - as Iterable); + Iterable get readUrls => (super.noSuchMethod( + Invocation.getter(#readUrls), + returnValue: [], + ) as Iterable); + + @override + Iterable get writeUrls => (super.noSuchMethod( + Invocation.getter(#writeUrls), + returnValue: [], + ) as Iterable); @override set pubKey(String? value) => super.noSuchMethod( - Invocation.setter(#pubKey, value), + Invocation.setter( + #pubKey, + value, + ), returnValueForMissingStub: null, ); @override set createdAt(int? value) => super.noSuchMethod( - Invocation.setter(#createdAt, value), + Invocation.setter( + #createdAt, + value, + ), returnValueForMissingStub: null, ); @override set refreshedTimestamp(int? value) => super.noSuchMethod( - Invocation.setter(#refreshedTimestamp, value), + Invocation.setter( + #refreshedTimestamp, + value, + ), returnValueForMissingStub: null, ); @override set relays(Map? value) => super.noSuchMethod( - Invocation.setter(#relays, value), + Invocation.setter( + #relays, + value, + ), returnValueForMissingStub: null, ); @override _i2.Nip65 toNip65() => (super.noSuchMethod( - Invocation.method(#toNip65, []), - returnValue: _FakeNip65_0(this, Invocation.method(#toNip65, [])), + Invocation.method( + #toNip65, + [], + ), + returnValue: _FakeNip65_0( + this, + Invocation.method( + #toNip65, + [], + ), + ), ) as _i2.Nip65); } @@ -136,24 +189,34 @@ class MockRelaySet extends _i1.Mock implements _i9.RelaySet { @override String get id => (super.noSuchMethod( Invocation.getter(#id), - returnValue: _i7.dummyValue(this, Invocation.getter(#id)), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#id), + ), ) as String); @override - Iterable get urls => - (super.noSuchMethod(Invocation.getter(#urls), returnValue: []) - as Iterable); + Iterable get urls => (super.noSuchMethod( + Invocation.getter(#urls), + returnValue: [], + ) as Iterable); @override String get name => (super.noSuchMethod( Invocation.getter(#name), - returnValue: _i7.dummyValue(this, Invocation.getter(#name)), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#name), + ), ) as String); @override String get pubKey => (super.noSuchMethod( Invocation.getter(#pubKey), - returnValue: _i7.dummyValue(this, Invocation.getter(#pubKey)), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#pubKey), + ), ) as String); @override @@ -188,45 +251,66 @@ class MockRelaySet extends _i1.Mock implements _i9.RelaySet { @override set name(String? value) => super.noSuchMethod( - Invocation.setter(#name, value), + Invocation.setter( + #name, + value, + ), returnValueForMissingStub: null, ); @override set pubKey(String? value) => super.noSuchMethod( - Invocation.setter(#pubKey, value), + Invocation.setter( + #pubKey, + value, + ), returnValueForMissingStub: null, ); @override set relayMinCountPerPubkey(int? value) => super.noSuchMethod( - Invocation.setter(#relayMinCountPerPubkey, value), + Invocation.setter( + #relayMinCountPerPubkey, + value, + ), returnValueForMissingStub: null, ); @override set direction(_i10.RelayDirection? value) => super.noSuchMethod( - Invocation.setter(#direction, value), + Invocation.setter( + #direction, + value, + ), returnValueForMissingStub: null, ); @override set relaysMap(Map>? value) => super.noSuchMethod( - Invocation.setter(#relaysMap, value), + Invocation.setter( + #relaysMap, + value, + ), returnValueForMissingStub: null, ); @override set fallbackToBootstrapRelays(bool? value) => super.noSuchMethod( - Invocation.setter(#fallbackToBootstrapRelays, value), + Invocation.setter( + #fallbackToBootstrapRelays, + value, + ), returnValueForMissingStub: null, ); @override set notCoveredPubkeys(List<_i9.NotCoveredPubKey>? value) => super.noSuchMethod( - Invocation.setter(#notCoveredPubkeys, value), + Invocation.setter( + #notCoveredPubkeys, + value, + ), returnValueForMissingStub: null, ); @@ -236,14 +320,23 @@ class MockRelaySet extends _i1.Mock implements _i9.RelaySet { _i13.RequestState? groupRequest, ) => super.noSuchMethod( - Invocation.method(#splitIntoRequests, [filter, groupRequest]), + Invocation.method( + #splitIntoRequests, + [ + filter, + groupRequest, + ], + ), returnValueForMissingStub: null, ); @override void addMoreRelays(Map>? more) => super.noSuchMethod( - Invocation.method(#addMoreRelays, [more]), + Invocation.method( + #addMoreRelays, + [more], + ), returnValueForMissingStub: null, ); } @@ -259,13 +352,17 @@ class MockContactList extends _i1.Mock implements _i14.ContactList { @override String get pubKey => (super.noSuchMethod( Invocation.getter(#pubKey), - returnValue: _i7.dummyValue(this, Invocation.getter(#pubKey)), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#pubKey), + ), ) as String); @override - List get contacts => - (super.noSuchMethod(Invocation.getter(#contacts), returnValue: []) - as List); + List get contacts => (super.noSuchMethod( + Invocation.getter(#contacts), + returnValue: [], + ) as List); @override List get contactRelays => (super.noSuchMethod( @@ -274,9 +371,10 @@ class MockContactList extends _i1.Mock implements _i14.ContactList { ) as List); @override - List get petnames => - (super.noSuchMethod(Invocation.getter(#petnames), returnValue: []) - as List); + List get petnames => (super.noSuchMethod( + Invocation.getter(#petnames), + returnValue: [], + ) as List); @override List get followedTags => (super.noSuchMethod( @@ -297,92 +395,145 @@ class MockContactList extends _i1.Mock implements _i14.ContactList { ) as List); @override - int get createdAt => - (super.noSuchMethod(Invocation.getter(#createdAt), returnValue: 0) - as int); + int get createdAt => (super.noSuchMethod( + Invocation.getter(#createdAt), + returnValue: 0, + ) as int); @override - List get sources => - (super.noSuchMethod(Invocation.getter(#sources), returnValue: []) - as List); + List get sources => (super.noSuchMethod( + Invocation.getter(#sources), + returnValue: [], + ) as List); @override set pubKey(String? value) => super.noSuchMethod( - Invocation.setter(#pubKey, value), + Invocation.setter( + #pubKey, + value, + ), returnValueForMissingStub: null, ); @override set contacts(List? value) => super.noSuchMethod( - Invocation.setter(#contacts, value), + Invocation.setter( + #contacts, + value, + ), returnValueForMissingStub: null, ); @override set contactRelays(List? value) => super.noSuchMethod( - Invocation.setter(#contactRelays, value), + Invocation.setter( + #contactRelays, + value, + ), returnValueForMissingStub: null, ); @override set petnames(List? value) => super.noSuchMethod( - Invocation.setter(#petnames, value), + Invocation.setter( + #petnames, + value, + ), returnValueForMissingStub: null, ); @override set followedTags(List? value) => super.noSuchMethod( - Invocation.setter(#followedTags, value), + Invocation.setter( + #followedTags, + value, + ), returnValueForMissingStub: null, ); @override set followedCommunities(List? value) => super.noSuchMethod( - Invocation.setter(#followedCommunities, value), + Invocation.setter( + #followedCommunities, + value, + ), returnValueForMissingStub: null, ); @override set followedEvents(List? value) => super.noSuchMethod( - Invocation.setter(#followedEvents, value), + Invocation.setter( + #followedEvents, + value, + ), returnValueForMissingStub: null, ); @override set createdAt(int? value) => super.noSuchMethod( - Invocation.setter(#createdAt, value), + Invocation.setter( + #createdAt, + value, + ), returnValueForMissingStub: null, ); @override set loadedTimestamp(int? value) => super.noSuchMethod( - Invocation.setter(#loadedTimestamp, value), + Invocation.setter( + #loadedTimestamp, + value, + ), returnValueForMissingStub: null, ); @override set sources(List? value) => super.noSuchMethod( - Invocation.setter(#sources, value), + Invocation.setter( + #sources, + value, + ), returnValueForMissingStub: null, ); @override List> contactsToJson() => (super.noSuchMethod( - Invocation.method(#contactsToJson, []), + Invocation.method( + #contactsToJson, + [], + ), returnValue: >[], ) as List>); @override - List> tagListToJson(List? list, String? tag) => + List> tagListToJson( + List? list, + String? tag, + ) => (super.noSuchMethod( - Invocation.method(#tagListToJson, [list, tag]), + Invocation.method( + #tagListToJson, + [ + list, + tag, + ], + ), returnValue: >[], ) as List>); @override _i3.Nip01Event toEvent() => (super.noSuchMethod( - Invocation.method(#toEvent, []), - returnValue: _FakeNip01Event_1(this, Invocation.method(#toEvent, [])), + Invocation.method( + #toEvent, + [], + ), + returnValue: _FakeNip01Event_1( + this, + Invocation.method( + #toEvent, + [], + ), + ), ) as _i3.Nip01Event); } @@ -397,7 +548,10 @@ class MockMetadata extends _i1.Mock implements _i4.Metadata { @override String get pubKey => (super.noSuchMethod( Invocation.getter(#pubKey), - returnValue: _i7.dummyValue(this, Invocation.getter(#pubKey)), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#pubKey), + ), ) as String); @override @@ -407,9 +561,10 @@ class MockMetadata extends _i1.Mock implements _i4.Metadata { ) as Map); @override - List get sources => - (super.noSuchMethod(Invocation.getter(#sources), returnValue: []) - as List); + List get sources => (super.noSuchMethod( + Invocation.getter(#sources), + returnValue: [], + ) as List); @override List> get tags => (super.noSuchMethod( @@ -419,126 +574,206 @@ class MockMetadata extends _i1.Mock implements _i4.Metadata { @override set pubKey(String? value) => super.noSuchMethod( - Invocation.setter(#pubKey, value), + Invocation.setter( + #pubKey, + value, + ), returnValueForMissingStub: null, ); @override set content(Map? value) => super.noSuchMethod( - Invocation.setter(#content, value), + Invocation.setter( + #content, + value, + ), returnValueForMissingStub: null, ); @override set name(String? value) => super.noSuchMethod( - Invocation.setter(#name, value), + Invocation.setter( + #name, + value, + ), returnValueForMissingStub: null, ); @override set displayName(String? value) => super.noSuchMethod( - Invocation.setter(#displayName, value), + Invocation.setter( + #displayName, + value, + ), returnValueForMissingStub: null, ); @override set picture(String? value) => super.noSuchMethod( - Invocation.setter(#picture, value), + Invocation.setter( + #picture, + value, + ), returnValueForMissingStub: null, ); @override set banner(String? value) => super.noSuchMethod( - Invocation.setter(#banner, value), + Invocation.setter( + #banner, + value, + ), returnValueForMissingStub: null, ); @override set website(String? value) => super.noSuchMethod( - Invocation.setter(#website, value), + Invocation.setter( + #website, + value, + ), returnValueForMissingStub: null, ); @override set about(String? value) => super.noSuchMethod( - Invocation.setter(#about, value), + Invocation.setter( + #about, + value, + ), returnValueForMissingStub: null, ); @override set nip05(String? value) => super.noSuchMethod( - Invocation.setter(#nip05, value), + Invocation.setter( + #nip05, + value, + ), returnValueForMissingStub: null, ); @override set lud16(String? value) => super.noSuchMethod( - Invocation.setter(#lud16, value), + Invocation.setter( + #lud16, + value, + ), returnValueForMissingStub: null, ); @override set lud06(String? value) => super.noSuchMethod( - Invocation.setter(#lud06, value), + Invocation.setter( + #lud06, + value, + ), returnValueForMissingStub: null, ); @override set updatedAt(int? value) => super.noSuchMethod( - Invocation.setter(#updatedAt, value), + Invocation.setter( + #updatedAt, + value, + ), returnValueForMissingStub: null, ); @override set refreshedTimestamp(int? value) => super.noSuchMethod( - Invocation.setter(#refreshedTimestamp, value), + Invocation.setter( + #refreshedTimestamp, + value, + ), returnValueForMissingStub: null, ); @override set sources(List? value) => super.noSuchMethod( - Invocation.setter(#sources, value), + Invocation.setter( + #sources, + value, + ), returnValueForMissingStub: null, ); @override set tags(List>? value) => super.noSuchMethod( - Invocation.setter(#tags, value), + Invocation.setter( + #tags, + value, + ), returnValueForMissingStub: null, ); @override Map toJson() => (super.noSuchMethod( - Invocation.method(#toJson, []), + Invocation.method( + #toJson, + [], + ), returnValue: {}, ) as Map); @override _i3.Nip01Event toEvent() => (super.noSuchMethod( - Invocation.method(#toEvent, []), - returnValue: _FakeNip01Event_1(this, Invocation.method(#toEvent, [])), + Invocation.method( + #toEvent, + [], + ), + returnValue: _FakeNip01Event_1( + this, + Invocation.method( + #toEvent, + [], + ), + ), ) as _i3.Nip01Event); @override - void setCustomField(String? key, dynamic value) => super.noSuchMethod( - Invocation.method(#setCustomField, [key, value]), + void setCustomField( + String? key, + dynamic value, + ) => + super.noSuchMethod( + Invocation.method( + #setCustomField, + [ + key, + value, + ], + ), returnValueForMissingStub: null, ); @override - dynamic getCustomField(String? key) => - super.noSuchMethod(Invocation.method(#getCustomField, [key])); + dynamic getCustomField(String? key) => super.noSuchMethod(Invocation.method( + #getCustomField, + [key], + )); @override String getName() => (super.noSuchMethod( - Invocation.method(#getName, []), - returnValue: - _i7.dummyValue(this, Invocation.method(#getName, [])), + Invocation.method( + #getName, + [], + ), + returnValue: _i7.dummyValue( + this, + Invocation.method( + #getName, + [], + ), + ), ) as String); @override bool matchesSearch(String? str) => (super.noSuchMethod( - Invocation.method(#matchesSearch, [str]), + Invocation.method( + #matchesSearch, + [str], + ), returnValue: false, ) as bool); @@ -561,26 +796,10 @@ class MockMetadata extends _i1.Mock implements _i4.Metadata { Map? content, }) => (super.noSuchMethod( - Invocation.method(#copyWith, [], { - #pubKey: pubKey, - #name: name, - #displayName: displayName, - #picture: picture, - #banner: banner, - #website: website, - #about: about, - #nip05: nip05, - #lud16: lud16, - #lud06: lud06, - #updatedAt: updatedAt, - #refreshedTimestamp: refreshedTimestamp, - #sources: sources, - #tags: tags, - #content: content, - }), - returnValue: _FakeMetadata_2( - this, - Invocation.method(#copyWith, [], { + Invocation.method( + #copyWith, + [], + { #pubKey: pubKey, #name: name, #displayName: displayName, @@ -596,7 +815,31 @@ class MockMetadata extends _i1.Mock implements _i4.Metadata { #sources: sources, #tags: tags, #content: content, - }), + }, + ), + returnValue: _FakeMetadata_2( + this, + Invocation.method( + #copyWith, + [], + { + #pubKey: pubKey, + #name: name, + #displayName: displayName, + #picture: picture, + #banner: banner, + #website: website, + #about: about, + #nip05: nip05, + #lud16: lud16, + #lud06: lud06, + #updatedAt: updatedAt, + #refreshedTimestamp: refreshedTimestamp, + #sources: sources, + #tags: tags, + #content: content, + }, + ), ), ) as _i4.Metadata); } @@ -612,23 +855,32 @@ class MockNip01Event extends _i1.Mock implements _i3.Nip01Event { @override String get id => (super.noSuchMethod( Invocation.getter(#id), - returnValue: _i7.dummyValue(this, Invocation.getter(#id)), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#id), + ), ) as String); @override String get pubKey => (super.noSuchMethod( Invocation.getter(#pubKey), - returnValue: _i7.dummyValue(this, Invocation.getter(#pubKey)), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#pubKey), + ), ) as String); @override - int get createdAt => - (super.noSuchMethod(Invocation.getter(#createdAt), returnValue: 0) - as int); + int get createdAt => (super.noSuchMethod( + Invocation.getter(#createdAt), + returnValue: 0, + ) as int); @override - int get kind => - (super.noSuchMethod(Invocation.getter(#kind), returnValue: 0) as int); + int get kind => (super.noSuchMethod( + Invocation.getter(#kind), + returnValue: 0, + ) as int); @override List> get tags => (super.noSuchMethod( @@ -639,23 +891,29 @@ class MockNip01Event extends _i1.Mock implements _i3.Nip01Event { @override String get content => (super.noSuchMethod( Invocation.getter(#content), - returnValue: _i7.dummyValue(this, Invocation.getter(#content)), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#content), + ), ) as String); @override - List get sources => - (super.noSuchMethod(Invocation.getter(#sources), returnValue: []) - as List); + List get sources => (super.noSuchMethod( + Invocation.getter(#sources), + returnValue: [], + ) as List); @override - List get tTags => - (super.noSuchMethod(Invocation.getter(#tTags), returnValue: []) - as List); + List get tTags => (super.noSuchMethod( + Invocation.getter(#tTags), + returnValue: [], + ) as List); @override - List get pTags => - (super.noSuchMethod(Invocation.getter(#pTags), returnValue: []) - as List); + List get pTags => (super.noSuchMethod( + Invocation.getter(#pTags), + returnValue: [], + ) as List); @override List get replyETags => (super.noSuchMethod( @@ -665,13 +923,19 @@ class MockNip01Event extends _i1.Mock implements _i3.Nip01Event { @override set id(String? value) => super.noSuchMethod( - Invocation.setter(#id, value), + Invocation.setter( + #id, + value, + ), returnValueForMissingStub: null, ); @override set createdAt(int? value) => super.noSuchMethod( - Invocation.setter(#createdAt, value), + Invocation.setter( + #createdAt, + value, + ), returnValueForMissingStub: null, ); @@ -688,20 +952,10 @@ class MockNip01Event extends _i1.Mock implements _i3.Nip01Event { List? sources, }) => (super.noSuchMethod( - Invocation.method(#copyWith, [], { - #id: id, - #pubKey: pubKey, - #createdAt: createdAt, - #kind: kind, - #tags: tags, - #content: content, - #sig: sig, - #validSig: validSig, - #sources: sources, - }), - returnValue: _FakeNip01Event_1( - this, - Invocation.method(#copyWith, [], { + Invocation.method( + #copyWith, + [], + { #id: id, #pubKey: pubKey, #createdAt: createdAt, @@ -711,19 +965,42 @@ class MockNip01Event extends _i1.Mock implements _i3.Nip01Event { #sig: sig, #validSig: validSig, #sources: sources, - }), + }, + ), + returnValue: _FakeNip01Event_1( + this, + Invocation.method( + #copyWith, + [], + { + #id: id, + #pubKey: pubKey, + #createdAt: createdAt, + #kind: kind, + #tags: tags, + #content: content, + #sig: sig, + #validSig: validSig, + #sources: sources, + }, + ), ), ) as _i3.Nip01Event); @override List getTags(String? tag) => (super.noSuchMethod( - Invocation.method(#getTags, [tag]), + Invocation.method( + #getTags, + [tag], + ), returnValue: [], ) as List); @override - String? getFirstTag(String? name) => - (super.noSuchMethod(Invocation.method(#getFirstTag, [name])) as String?); + String? getFirstTag(String? name) => (super.noSuchMethod(Invocation.method( + #getFirstTag, + [name], + )) as String?); } /// A class which mocks [Nip05]. @@ -737,47 +1014,69 @@ class MockNip05 extends _i1.Mock implements _i5.Nip05 { @override String get pubKey => (super.noSuchMethod( Invocation.getter(#pubKey), - returnValue: _i7.dummyValue(this, Invocation.getter(#pubKey)), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#pubKey), + ), ) as String); @override String get nip05 => (super.noSuchMethod( Invocation.getter(#nip05), - returnValue: _i7.dummyValue(this, Invocation.getter(#nip05)), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#nip05), + ), ) as String); @override - bool get valid => - (super.noSuchMethod(Invocation.getter(#valid), returnValue: false) - as bool); + bool get valid => (super.noSuchMethod( + Invocation.getter(#valid), + returnValue: false, + ) as bool); @override set pubKey(String? value) => super.noSuchMethod( - Invocation.setter(#pubKey, value), + Invocation.setter( + #pubKey, + value, + ), returnValueForMissingStub: null, ); @override set nip05(String? value) => super.noSuchMethod( - Invocation.setter(#nip05, value), + Invocation.setter( + #nip05, + value, + ), returnValueForMissingStub: null, ); @override set valid(bool? value) => super.noSuchMethod( - Invocation.setter(#valid, value), + Invocation.setter( + #valid, + value, + ), returnValueForMissingStub: null, ); @override set networkFetchTime(int? value) => super.noSuchMethod( - Invocation.setter(#networkFetchTime, value), + Invocation.setter( + #networkFetchTime, + value, + ), returnValueForMissingStub: null, ); @override set relays(List? value) => super.noSuchMethod( - Invocation.setter(#relays, value), + Invocation.setter( + #relays, + value, + ), returnValueForMissingStub: null, ); @@ -790,22 +1089,30 @@ class MockNip05 extends _i1.Mock implements _i5.Nip05 { List? relays, }) => (super.noSuchMethod( - Invocation.method(#copyWith, [], { - #pubKey: pubKey, - #nip05: nip05, - #valid: valid, - #networkFetchTime: networkFetchTime, - #relays: relays, - }), - returnValue: _FakeNip05_3( - this, - Invocation.method(#copyWith, [], { + Invocation.method( + #copyWith, + [], + { #pubKey: pubKey, #nip05: nip05, #valid: valid, #networkFetchTime: networkFetchTime, #relays: relays, - }), + }, + ), + returnValue: _FakeNip05_3( + this, + Invocation.method( + #copyWith, + [], + { + #pubKey: pubKey, + #nip05: nip05, + #valid: valid, + #networkFetchTime: networkFetchTime, + #relays: relays, + }, + ), ), ) as _i5.Nip05); } diff --git a/packages/ndk/test/data_layer/nostr_transport/websocket_nostr_transport_test.mocks.dart b/packages/ndk/test/data_layer/nostr_transport/websocket_nostr_transport_test.mocks.dart index bfa38602a..42ee6f861 100644 --- a/packages/ndk/test/data_layer/nostr_transport/websocket_nostr_transport_test.mocks.dart +++ b/packages/ndk/test/data_layer/nostr_transport/websocket_nostr_transport_test.mocks.dart @@ -26,14 +26,24 @@ import 'package:web_socket_channel/web_socket_channel.dart' as _i2; class _FakeWebSocketChannel_0 extends _i1.SmartFake implements _i2.WebSocketChannel { - _FakeWebSocketChannel_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeWebSocketChannel_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } class _FakeStreamSubscription_1 extends _i1.SmartFake implements _i3.StreamSubscription { - _FakeStreamSubscription_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeStreamSubscription_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } /// A class which mocks [WebsocketDS]. @@ -61,13 +71,22 @@ class MockWebsocketDS extends _i1.Mock implements _i4.WebsocketDS { }) => (super.noSuchMethod( Invocation.method( - #listen, [onData], {#onError: onError, #onDone: onDone}), + #listen, + [onData], + { + #onError: onError, + #onDone: onDone, + }, + ), returnValue: _FakeStreamSubscription_1( this, Invocation.method( #listen, [onData], - {#onError: onError, #onDone: onDone}, + { + #onError: onError, + #onDone: onDone, + }, ), ), returnValueForMissingStub: _FakeStreamSubscription_1( @@ -75,34 +94,49 @@ class MockWebsocketDS extends _i1.Mock implements _i4.WebsocketDS { Invocation.method( #listen, [onData], - {#onError: onError, #onDone: onDone}, + { + #onError: onError, + #onDone: onDone, + }, ), ), ) as _i3.StreamSubscription); @override void send(dynamic data) => super.noSuchMethod( - Invocation.method(#send, [data]), + Invocation.method( + #send, + [data], + ), returnValueForMissingStub: null, ); @override _i3.Future ready() => (super.noSuchMethod( - Invocation.method(#ready, []), + Invocation.method( + #ready, + [], + ), returnValue: _i3.Future.value(), returnValueForMissingStub: _i3.Future.value(), ) as _i3.Future); @override _i3.Future close() => (super.noSuchMethod( - Invocation.method(#close, []), + Invocation.method( + #close, + [], + ), returnValue: _i3.Future.value(), returnValueForMissingStub: _i3.Future.value(), ) as _i3.Future); @override bool isOpen() => (super.noSuchMethod( - Invocation.method(#isOpen, []), + Invocation.method( + #isOpen, + [], + ), returnValue: false, returnValueForMissingStub: false, ) as bool); diff --git a/packages/ndk/test/entities/bolt12_wallet_test.dart b/packages/ndk/test/entities/bolt12_wallet_test.dart new file mode 100644 index 000000000..1ecf9c07a --- /dev/null +++ b/packages/ndk/test/entities/bolt12_wallet_test.dart @@ -0,0 +1,115 @@ +import 'package:ndk/entities.dart'; +import 'package:test/test.dart'; + +const _offer = + 'lno1pqqq5xj5wajkcan9gdshx6pq23jhxarfdenjqstyv3ex2umnzcss80xkrjkyrjk43u5dgu8f6a450fg2cnjtg7lhg76c3gtk5gdhshns'; +const _blindedPathOffer = + 'lno1pgqppmsrse80qf0aara4slvcjxrvu6j2rp5ftmjy4yntlsmsutpkvkt6878sx37ttar5fpecarm57v2y2can2uxq02l7k0er7czs6gsuzkdhe4tlqgpat4k4mrvvjwla3whdhmkvdtfq98w4jlg8wgsf26cndmndd0c33fqqx0y9hunesw4caaxfnw3uam5yy4kxtuqvujapdx93sd24wt7mdpeukuw46tp5zugxceqrr2ffkzpjcen3p77sy8jk8v7h04wlp9lg6ls76xqcn3nethq7e7553xn3vugt5vzlea2sqqedvc6k8r8hetzw9tvnlnw9muh4vaywdn5jgvj80ad3r9600ang39vvjnvn0aytg07ss05v6g9ru45p2srs'; + +void main() { + group('Bolt12WalletProvider input resolution', () { + test('accepts and decodes a direct offer', () async { + final resolved = await Bolt12WalletProvider.resolveInput(_offer); + + expect(resolved.offer, _offer); + expect(resolved.decoded['type'], 'offer'); + expect(resolved.decoded['valid'], isTrue); + expect(resolved.decoded['offer_description'], isNotEmpty); + }); + + test('extracts an offer from a BIP321 URI', () async { + final resolved = await Bolt12WalletProvider.resolveInput( + 'bitcoin:?amount=1&lno=${_offer.toUpperCase()}', + ); + + expect(resolved.offer, _offer); + expect(resolved.bip353Address, isNull); + }); + + test('accepts the commonly scanned bitcoin?lno shorthand', () async { + final resolved = await Bolt12WalletProvider.resolveInput( + 'bitcoin?lno=$_offer', + ); + + expect(resolved.offer, _offer); + }); + + test('accepts a current blinded-path offer in BIP321', () async { + final resolved = await Bolt12WalletProvider.resolveInput( + 'bitcoin:?lno=$_blindedPathOffer', + ); + + expect(resolved.offer, _blindedPathOffer); + expect(resolved.decoded['type'], 'offer'); + expect(resolved.decoded['valid'], isTrue); + expect(resolved.toMetadata()['hasBlindedPaths'], isTrue); + expect(resolved.toMetadata()['description'], isNull); + expect(resolved.toMetadata()['amount'], isNull); + }); + + test('resolves BIP353 and remembers its address', () async { + String? requestedAddress; + final resolved = await Bolt12WalletProvider.resolveInput( + '₿alice@example.com', + bip353Resolver: (address) async { + requestedAddress = address; + return _offer; + }, + ); + + expect(requestedAddress, 'alice@example.com'); + expect(resolved.offer, _offer); + expect(resolved.bip353Address, 'alice@example.com'); + }); + + test('rejects BIP353 records without an offer', () async { + expect( + () => Bolt12WalletProvider.resolveInput( + 'alice@example.com', + bip353Resolver: (_) async => null, + ), + throwsA(isA()), + ); + }); + + test('rejects malformed input', () async { + expect( + () => Bolt12WalletProvider.resolveInput('not a payment target'), + throwsA(isA()), + ); + }); + }); + + test('wallet is receive-only and round-trips through storage', () async { + final resolved = await Bolt12WalletProvider.resolveInput(_offer); + const provider = Bolt12WalletProvider(); + final wallet = provider.createWallet( + id: 'bolt12-1', + name: 'Donations', + supportedUnits: {'sat'}, + metadata: resolved.toMetadata(), + ) as Bolt12Wallet; + + expect(wallet.canReceive, isTrue); + expect(wallet.canSend, isFalse); + expect(await provider.receive(wallet, 123), _offer); + final bip321 = await provider.receiveBip321(wallet); + expect(bip321.bip321, 'bitcoin:?lno=$_offer'); + expect( + () => provider.send(wallet, 'lnbc...'), + throwsA(isA()), + ); + + final restored = WalletFactory.fromStorage( + id: wallet.id, + name: wallet.name, + type: wallet.type, + supportedUnits: wallet.supportedUnits, + metadata: wallet.toMetadata(), + ) as Bolt12Wallet; + expect(restored.offer, wallet.offer); + expect(restored.description, wallet.description); + expect(restored.issuer, wallet.issuer); + expect(restored.hasBlindedPaths, wallet.hasBlindedPaths); + }); +} diff --git a/packages/ndk/test/entities/nwc_wallet_test.dart b/packages/ndk/test/entities/nwc_wallet_test.dart index 0bad3c59c..85dd3f140 100644 --- a/packages/ndk/test/entities/nwc_wallet_test.dart +++ b/packages/ndk/test/entities/nwc_wallet_test.dart @@ -1,4 +1,5 @@ import 'package:ndk/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart'; +import 'package:ndk/domain_layer/entities/wallet/wallet.dart'; import 'package:ndk/domain_layer/usecases/nwc/consts/nwc_method.dart'; import 'package:test/test.dart'; @@ -64,6 +65,16 @@ void main() { expect(wallet.canSend, isTrue); expect(wallet.canReceive, isTrue); + expect(wallet.supportsBip321Pay, isTrue); + expect(wallet.supportsBip321Receive, isTrue); + expect( + wallet.sendPaymentProtocols, + containsAll(WalletPaymentProtocol.values), + ); + expect( + wallet.receivePaymentProtocols, + containsAll(WalletPaymentProtocol.values), + ); }); }); } diff --git a/packages/ndk/test/usecases/lnurl/lnurl_test.mocks.dart b/packages/ndk/test/usecases/lnurl/lnurl_test.mocks.dart index 041fdb98f..31c776521 100644 --- a/packages/ndk/test/usecases/lnurl/lnurl_test.mocks.dart +++ b/packages/ndk/test/usecases/lnurl/lnurl_test.mocks.dart @@ -27,14 +27,24 @@ import 'package:mockito/src/dummies.dart' as _i5; // ignore_for_file: invalid_use_of_internal_member class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response { - _FakeResponse_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeResponse_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } class _FakeStreamedResponse_1 extends _i1.SmartFake implements _i2.StreamedResponse { - _FakeStreamedResponse_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeStreamedResponse_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } /// A class which mocks [Client]. @@ -46,27 +56,45 @@ class MockClient extends _i1.Mock implements _i2.Client { } @override - _i3.Future<_i2.Response> head(Uri? url, {Map? headers}) => + _i3.Future<_i2.Response> head( + Uri? url, { + Map? headers, + }) => (super.noSuchMethod( - Invocation.method(#head, [url], {#headers: headers}), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method(#head, [url], {#headers: headers}), - ), + Invocation.method( + #head, + [url], + {#headers: headers}, ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #head, + [url], + {#headers: headers}, + ), + )), ) as _i3.Future<_i2.Response>); @override - _i3.Future<_i2.Response> get(Uri? url, {Map? headers}) => + _i3.Future<_i2.Response> get( + Uri? url, { + Map? headers, + }) => (super.noSuchMethod( - Invocation.method(#get, [url], {#headers: headers}), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method(#get, [url], {#headers: headers}), - ), + Invocation.method( + #get, + [url], + {#headers: headers}, ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #get, + [url], + {#headers: headers}, + ), + )), ) as _i3.Future<_i2.Response>); @override @@ -80,18 +108,24 @@ class MockClient extends _i1.Mock implements _i2.Client { Invocation.method( #post, [url], - {#headers: headers, #body: body, #encoding: encoding}, + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method( - #post, - [url], - {#headers: headers, #body: body, #encoding: encoding}, - ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #post, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - ), + )), ) as _i3.Future<_i2.Response>); @override @@ -105,18 +139,24 @@ class MockClient extends _i1.Mock implements _i2.Client { Invocation.method( #put, [url], - {#headers: headers, #body: body, #encoding: encoding}, + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method( - #put, - [url], - {#headers: headers, #body: body, #encoding: encoding}, - ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #put, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - ), + )), ) as _i3.Future<_i2.Response>); @override @@ -130,18 +170,24 @@ class MockClient extends _i1.Mock implements _i2.Client { Invocation.method( #patch, [url], - {#headers: headers, #body: body, #encoding: encoding}, + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method( - #patch, - [url], - {#headers: headers, #body: body, #encoding: encoding}, - ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #patch, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - ), + )), ) as _i3.Future<_i2.Response>); @override @@ -155,30 +201,45 @@ class MockClient extends _i1.Mock implements _i2.Client { Invocation.method( #delete, [url], - {#headers: headers, #body: body, #encoding: encoding}, + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method( - #delete, - [url], - {#headers: headers, #body: body, #encoding: encoding}, - ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #delete, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - ), + )), ) as _i3.Future<_i2.Response>); @override - _i3.Future read(Uri? url, {Map? headers}) => + _i3.Future read( + Uri? url, { + Map? headers, + }) => (super.noSuchMethod( - Invocation.method(#read, [url], {#headers: headers}), - returnValue: _i3.Future.value( - _i5.dummyValue( - this, - Invocation.method(#read, [url], {#headers: headers}), - ), + Invocation.method( + #read, + [url], + {#headers: headers}, ), + returnValue: _i3.Future.value(_i5.dummyValue( + this, + Invocation.method( + #read, + [url], + {#headers: headers}, + ), + )), ) as _i3.Future); @override @@ -187,22 +248,37 @@ class MockClient extends _i1.Mock implements _i2.Client { Map? headers, }) => (super.noSuchMethod( - Invocation.method(#readBytes, [url], {#headers: headers}), + Invocation.method( + #readBytes, + [url], + {#headers: headers}, + ), returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), ) as _i3.Future<_i6.Uint8List>); @override _i3.Future<_i2.StreamedResponse> send(_i2.BaseRequest? request) => (super.noSuchMethod( - Invocation.method(#send, [request]), - returnValue: _i3.Future<_i2.StreamedResponse>.value( - _FakeStreamedResponse_1(this, Invocation.method(#send, [request])), + Invocation.method( + #send, + [request], ), + returnValue: + _i3.Future<_i2.StreamedResponse>.value(_FakeStreamedResponse_1( + this, + Invocation.method( + #send, + [request], + ), + )), ) as _i3.Future<_i2.StreamedResponse>); @override void close() => super.noSuchMethod( - Invocation.method(#close, []), + Invocation.method( + #close, + [], + ), returnValueForMissingStub: null, ); } diff --git a/packages/ndk/test/usecases/nip05/nip05_network_test.mocks.dart b/packages/ndk/test/usecases/nip05/nip05_network_test.mocks.dart index 86bbf33d3..8b14136e5 100644 --- a/packages/ndk/test/usecases/nip05/nip05_network_test.mocks.dart +++ b/packages/ndk/test/usecases/nip05/nip05_network_test.mocks.dart @@ -27,14 +27,24 @@ import 'package:mockito/src/dummies.dart' as _i5; // ignore_for_file: invalid_use_of_internal_member class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response { - _FakeResponse_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeResponse_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } class _FakeStreamedResponse_1 extends _i1.SmartFake implements _i2.StreamedResponse { - _FakeStreamedResponse_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeStreamedResponse_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } /// A class which mocks [Client]. @@ -46,27 +56,45 @@ class MockClient extends _i1.Mock implements _i2.Client { } @override - _i3.Future<_i2.Response> head(Uri? url, {Map? headers}) => + _i3.Future<_i2.Response> head( + Uri? url, { + Map? headers, + }) => (super.noSuchMethod( - Invocation.method(#head, [url], {#headers: headers}), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method(#head, [url], {#headers: headers}), - ), + Invocation.method( + #head, + [url], + {#headers: headers}, ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #head, + [url], + {#headers: headers}, + ), + )), ) as _i3.Future<_i2.Response>); @override - _i3.Future<_i2.Response> get(Uri? url, {Map? headers}) => + _i3.Future<_i2.Response> get( + Uri? url, { + Map? headers, + }) => (super.noSuchMethod( - Invocation.method(#get, [url], {#headers: headers}), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method(#get, [url], {#headers: headers}), - ), + Invocation.method( + #get, + [url], + {#headers: headers}, ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #get, + [url], + {#headers: headers}, + ), + )), ) as _i3.Future<_i2.Response>); @override @@ -80,18 +108,24 @@ class MockClient extends _i1.Mock implements _i2.Client { Invocation.method( #post, [url], - {#headers: headers, #body: body, #encoding: encoding}, + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method( - #post, - [url], - {#headers: headers, #body: body, #encoding: encoding}, - ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #post, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - ), + )), ) as _i3.Future<_i2.Response>); @override @@ -105,18 +139,24 @@ class MockClient extends _i1.Mock implements _i2.Client { Invocation.method( #put, [url], - {#headers: headers, #body: body, #encoding: encoding}, + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method( - #put, - [url], - {#headers: headers, #body: body, #encoding: encoding}, - ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #put, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - ), + )), ) as _i3.Future<_i2.Response>); @override @@ -130,18 +170,24 @@ class MockClient extends _i1.Mock implements _i2.Client { Invocation.method( #patch, [url], - {#headers: headers, #body: body, #encoding: encoding}, + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method( - #patch, - [url], - {#headers: headers, #body: body, #encoding: encoding}, - ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #patch, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - ), + )), ) as _i3.Future<_i2.Response>); @override @@ -155,30 +201,45 @@ class MockClient extends _i1.Mock implements _i2.Client { Invocation.method( #delete, [url], - {#headers: headers, #body: body, #encoding: encoding}, + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method( - #delete, - [url], - {#headers: headers, #body: body, #encoding: encoding}, - ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #delete, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - ), + )), ) as _i3.Future<_i2.Response>); @override - _i3.Future read(Uri? url, {Map? headers}) => + _i3.Future read( + Uri? url, { + Map? headers, + }) => (super.noSuchMethod( - Invocation.method(#read, [url], {#headers: headers}), - returnValue: _i3.Future.value( - _i5.dummyValue( - this, - Invocation.method(#read, [url], {#headers: headers}), - ), + Invocation.method( + #read, + [url], + {#headers: headers}, ), + returnValue: _i3.Future.value(_i5.dummyValue( + this, + Invocation.method( + #read, + [url], + {#headers: headers}, + ), + )), ) as _i3.Future); @override @@ -187,22 +248,37 @@ class MockClient extends _i1.Mock implements _i2.Client { Map? headers, }) => (super.noSuchMethod( - Invocation.method(#readBytes, [url], {#headers: headers}), + Invocation.method( + #readBytes, + [url], + {#headers: headers}, + ), returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), ) as _i3.Future<_i6.Uint8List>); @override _i3.Future<_i2.StreamedResponse> send(_i2.BaseRequest? request) => (super.noSuchMethod( - Invocation.method(#send, [request]), - returnValue: _i3.Future<_i2.StreamedResponse>.value( - _FakeStreamedResponse_1(this, Invocation.method(#send, [request])), + Invocation.method( + #send, + [request], ), + returnValue: + _i3.Future<_i2.StreamedResponse>.value(_FakeStreamedResponse_1( + this, + Invocation.method( + #send, + [request], + ), + )), ) as _i3.Future<_i2.StreamedResponse>); @override void close() => super.noSuchMethod( - Invocation.method(#close, []), + Invocation.method( + #close, + [], + ), returnValueForMissingStub: null, ); } diff --git a/packages/ndk/test/usecases/wallets_transfer_test.dart b/packages/ndk/test/usecases/wallets_transfer_test.dart new file mode 100644 index 000000000..03cf13222 --- /dev/null +++ b/packages/ndk/test/usecases/wallets_transfer_test.dart @@ -0,0 +1,285 @@ +import 'package:ndk/data_layer/repositories/wallets/mem_wallets_repo.dart'; +import 'package:ndk/entities.dart'; +import 'package:ndk/domain_layer/usecases/nwc/responses/pay_invoice_response.dart'; +import 'package:ndk/domain_layer/usecases/nwc/responses/pay_response.dart'; +import 'package:ndk/domain_layer/usecases/nwc/responses/receive_response.dart'; +import 'package:test/test.dart'; + +void main() { + test('transfers to a BOLT12-only wallet through BIP-321', () async { + final source = _TestWallet( + id: 'nwc-source', + type: WalletType.NWC, + canSendValue: true, + sendProtocols: const {WalletPaymentProtocol.bolt12}, + supportsBip321PayValue: true, + ); + final destination = _TestWallet( + id: 'bolt12-destination', + type: WalletType.BOLT12, + canReceiveValue: true, + receiveProtocols: const {WalletPaymentProtocol.bolt12}, + supportsBip321ReceiveValue: true, + supportsBolt11InvoiceReceiveValue: false, + ); + final sourceProvider = _TestWalletProvider(source.type); + final destinationProvider = _TestWalletProvider(destination.type) + ..bip321ToReceive = 'bitcoin:?lno=lno1offer'; + final wallets = await _wallets( + [source, destination], + [sourceProvider, destinationProvider], + ); + addTearDown(wallets.dispose); + + expect( + wallets.compatibleTransferProtocol( + source: source, + destination: destination, + ), + WalletPaymentProtocol.bolt12, + ); + + final result = await wallets.transfer( + sourceWalletId: source.id, + destinationWalletId: destination.id, + amountMsat: 21000, + ); + + expect(result.protocol, WalletPaymentProtocol.bolt12); + expect(destinationProvider.receivedAmountMsat, 21000); + expect(destinationProvider.receivedMetadata, isNull); + expect(sourceProvider.paidPayment, 'bitcoin:?lno=lno1offer'); + expect(sourceProvider.paidAmountMsat, 21000); + expect(sourceProvider.paidMetadata, isNull); + }); + + test('transfers between legacy wallets with a fresh BOLT11 invoice', + () async { + final source = _TestWallet( + id: 'cashu-source', + type: WalletType.CASHU, + canSendValue: true, + ); + final destination = _TestWallet( + id: 'lnurl-destination', + type: WalletType.LNURL, + canReceiveValue: true, + ); + final sourceProvider = _TestWalletProvider(source.type); + final destinationProvider = _TestWalletProvider(destination.type) + ..invoiceToReceive = 'lnbc1internaltransfer'; + final wallets = await _wallets( + [source, destination], + [sourceProvider, destinationProvider], + ); + addTearDown(wallets.dispose); + + final result = await wallets.transfer( + sourceWalletId: source.id, + destinationWalletId: destination.id, + amountMsat: 42000, + ); + + expect(result.protocol, WalletPaymentProtocol.bolt11); + expect(destinationProvider.receivedAmountSats, 42); + expect(sourceProvider.paidInvoice, 'lnbc1internaltransfer'); + expect( + result.payment, + 'bitcoin:?lightning=lnbc1internaltransfer', + ); + }); + + test('does not offer a BOLT12 destination to a legacy-only sender', () { + final source = _TestWallet( + id: 'legacy-source', + type: WalletType.CASHU, + canSendValue: true, + ); + final destination = _TestWallet( + id: 'bolt12-destination', + type: WalletType.BOLT12, + canReceiveValue: true, + receiveProtocols: const {WalletPaymentProtocol.bolt12}, + supportsBip321ReceiveValue: true, + supportsBolt11InvoiceReceiveValue: false, + ); + final wallets = Wallets( + providers: const [], + repository: MemWalletsRepo(), + ); + addTearDown(wallets.dispose); + + expect( + wallets.compatibleTransferProtocol( + source: source, + destination: destination, + ), + isNull, + ); + }); +} + +Future _wallets( + List walletList, + List providers, +) async { + final repository = MemWalletsRepo(); + for (final wallet in walletList) { + await repository.storeWallet(wallet); + } + final wallets = Wallets(providers: providers, repository: repository); + await wallets.getWallets(); + return wallets; +} + +class _TestWallet extends Wallet { + final bool canSendValue; + final bool canReceiveValue; + final Set? sendProtocols; + final Set? receiveProtocols; + final bool supportsBip321PayValue; + final bool supportsBip321ReceiveValue; + final bool? supportsBolt11InvoiceReceiveValue; + + _TestWallet({ + required super.id, + required super.type, + this.canSendValue = false, + this.canReceiveValue = false, + this.sendProtocols, + this.receiveProtocols, + this.supportsBip321PayValue = false, + this.supportsBip321ReceiveValue = false, + this.supportsBolt11InvoiceReceiveValue, + }) : super( + name: id, + supportedUnits: const {'sat'}, + metadata: const {}, + ); + + @override + bool get canReceive => canReceiveValue; + + @override + bool get canSend => canSendValue; + + @override + Set get sendPaymentProtocols => + sendProtocols ?? super.sendPaymentProtocols; + + @override + Set get receivePaymentProtocols => + receiveProtocols ?? super.receivePaymentProtocols; + + @override + bool get supportsBip321Pay => supportsBip321PayValue; + + @override + bool get supportsBip321Receive => supportsBip321ReceiveValue; + + @override + bool get supportsBolt11InvoiceReceive => + supportsBolt11InvoiceReceiveValue ?? super.supportsBolt11InvoiceReceive; + + @override + Map toMetadata() => metadata; +} + +class _TestWalletProvider implements WalletProvider { + @override + final WalletType type; + + String invoiceToReceive = 'lnbc1invoice'; + String bip321ToReceive = 'bitcoin:?lightning=lnbc1invoice'; + int? receivedAmountSats; + int? receivedAmountMsat; + String? paidInvoice; + String? paidPayment; + int? paidAmountMsat; + Map? paidMetadata; + Map? receivedMetadata; + + _TestWalletProvider(this.type); + + @override + Wallet createWallet({ + required String id, + required String name, + required Set supportedUnits, + required Map metadata, + }) => + throw UnimplementedError(); + + @override + Stream> get discoveredWallets => Stream.value(const []); + + @override + Stream> getBalances(Wallet wallet) => + Stream.value(const []); + + @override + Stream> getPendingTransactions(Wallet wallet) => + Stream.value(const []); + + @override + Stream> getRecentTransactions(Wallet wallet) => + Stream.value(const []); + + @override + Future initialize(Wallet wallet) async => null; + + @override + Future removeWallet(Wallet wallet) async {} + + @override + Future send( + Wallet wallet, + String invoice, { + Duration? timeout, + }) async { + paidInvoice = invoice; + return PayInvoiceResponse(resultType: 'pay_invoice', feesPaid: 0); + } + + @override + Future receive(Wallet wallet, int amountSats) async { + receivedAmountSats = amountSats; + return invoiceToReceive; + } + + @override + Future payBip321( + Wallet wallet, { + required String payment, + int? amountMsat, + String? payerNote, + Map? metadata, + Duration? timeout, + }) async { + paidPayment = payment; + paidAmountMsat = amountMsat; + paidMetadata = metadata; + return PayResponse( + resultType: 'pay', + transactionId: 'transfer', + state: 'settled', + instructionType: payment.contains('lno=') ? 'bolt12' : 'bolt11', + amountMsat: amountMsat ?? 0, + feesPaid: 0, + createdAt: 1700000000, + ); + } + + @override + Future receiveBip321( + Wallet wallet, { + int? amountMsat, + String? description, + Map? metadata, + Duration? timeout, + }) async { + receivedAmountMsat = amountMsat; + receivedMetadata = metadata; + return ReceiveResponse(resultType: 'receive', bip321: bip321ToReceive); + } +} diff --git a/packages/ndk/test/usecases/zaps/zap_receipt_test.mocks.dart b/packages/ndk/test/usecases/zaps/zap_receipt_test.mocks.dart index 8105fb162..f9138f597 100644 --- a/packages/ndk/test/usecases/zaps/zap_receipt_test.mocks.dart +++ b/packages/ndk/test/usecases/zaps/zap_receipt_test.mocks.dart @@ -23,8 +23,13 @@ import 'package:ndk/domain_layer/entities/nip_01_event.dart' as _i2; // ignore_for_file: invalid_use_of_internal_member class _FakeNip01Event_0 extends _i1.SmartFake implements _i2.Nip01Event { - _FakeNip01Event_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeNip01Event_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } /// A class which mocks [Nip01Event]. @@ -38,23 +43,32 @@ class MockNip01Event extends _i1.Mock implements _i2.Nip01Event { @override String get id => (super.noSuchMethod( Invocation.getter(#id), - returnValue: _i3.dummyValue(this, Invocation.getter(#id)), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#id), + ), ) as String); @override String get pubKey => (super.noSuchMethod( Invocation.getter(#pubKey), - returnValue: _i3.dummyValue(this, Invocation.getter(#pubKey)), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#pubKey), + ), ) as String); @override - int get createdAt => - (super.noSuchMethod(Invocation.getter(#createdAt), returnValue: 0) - as int); + int get createdAt => (super.noSuchMethod( + Invocation.getter(#createdAt), + returnValue: 0, + ) as int); @override - int get kind => - (super.noSuchMethod(Invocation.getter(#kind), returnValue: 0) as int); + int get kind => (super.noSuchMethod( + Invocation.getter(#kind), + returnValue: 0, + ) as int); @override List> get tags => (super.noSuchMethod( @@ -65,23 +79,29 @@ class MockNip01Event extends _i1.Mock implements _i2.Nip01Event { @override String get content => (super.noSuchMethod( Invocation.getter(#content), - returnValue: _i3.dummyValue(this, Invocation.getter(#content)), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#content), + ), ) as String); @override - List get sources => - (super.noSuchMethod(Invocation.getter(#sources), returnValue: []) - as List); + List get sources => (super.noSuchMethod( + Invocation.getter(#sources), + returnValue: [], + ) as List); @override - List get tTags => - (super.noSuchMethod(Invocation.getter(#tTags), returnValue: []) - as List); + List get tTags => (super.noSuchMethod( + Invocation.getter(#tTags), + returnValue: [], + ) as List); @override - List get pTags => - (super.noSuchMethod(Invocation.getter(#pTags), returnValue: []) - as List); + List get pTags => (super.noSuchMethod( + Invocation.getter(#pTags), + returnValue: [], + ) as List); @override List get replyETags => (super.noSuchMethod( @@ -91,13 +111,19 @@ class MockNip01Event extends _i1.Mock implements _i2.Nip01Event { @override set id(String? value) => super.noSuchMethod( - Invocation.setter(#id, value), + Invocation.setter( + #id, + value, + ), returnValueForMissingStub: null, ); @override set createdAt(int? value) => super.noSuchMethod( - Invocation.setter(#createdAt, value), + Invocation.setter( + #createdAt, + value, + ), returnValueForMissingStub: null, ); @@ -114,20 +140,10 @@ class MockNip01Event extends _i1.Mock implements _i2.Nip01Event { List? sources, }) => (super.noSuchMethod( - Invocation.method(#copyWith, [], { - #id: id, - #pubKey: pubKey, - #createdAt: createdAt, - #kind: kind, - #tags: tags, - #content: content, - #sig: sig, - #validSig: validSig, - #sources: sources, - }), - returnValue: _FakeNip01Event_0( - this, - Invocation.method(#copyWith, [], { + Invocation.method( + #copyWith, + [], + { #id: id, #pubKey: pubKey, #createdAt: createdAt, @@ -137,17 +153,40 @@ class MockNip01Event extends _i1.Mock implements _i2.Nip01Event { #sig: sig, #validSig: validSig, #sources: sources, - }), + }, + ), + returnValue: _FakeNip01Event_0( + this, + Invocation.method( + #copyWith, + [], + { + #id: id, + #pubKey: pubKey, + #createdAt: createdAt, + #kind: kind, + #tags: tags, + #content: content, + #sig: sig, + #validSig: validSig, + #sources: sources, + }, + ), ), ) as _i2.Nip01Event); @override List getTags(String? tag) => (super.noSuchMethod( - Invocation.method(#getTags, [tag]), + Invocation.method( + #getTags, + [tag], + ), returnValue: [], ) as List); @override - String? getFirstTag(String? name) => - (super.noSuchMethod(Invocation.method(#getFirstTag, [name])) as String?); + String? getFirstTag(String? name) => (super.noSuchMethod(Invocation.method( + #getFirstTag, + [name], + )) as String?); } diff --git a/packages/ndk/test/usecases/zaps/zaps_test.mocks.dart b/packages/ndk/test/usecases/zaps/zaps_test.mocks.dart index df1cc4cbc..7a49df125 100644 --- a/packages/ndk/test/usecases/zaps/zaps_test.mocks.dart +++ b/packages/ndk/test/usecases/zaps/zaps_test.mocks.dart @@ -27,14 +27,24 @@ import 'package:mockito/src/dummies.dart' as _i5; // ignore_for_file: invalid_use_of_internal_member class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response { - _FakeResponse_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeResponse_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } class _FakeStreamedResponse_1 extends _i1.SmartFake implements _i2.StreamedResponse { - _FakeStreamedResponse_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeStreamedResponse_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } /// A class which mocks [Client]. @@ -46,27 +56,45 @@ class MockClient extends _i1.Mock implements _i2.Client { } @override - _i3.Future<_i2.Response> head(Uri? url, {Map? headers}) => + _i3.Future<_i2.Response> head( + Uri? url, { + Map? headers, + }) => (super.noSuchMethod( - Invocation.method(#head, [url], {#headers: headers}), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method(#head, [url], {#headers: headers}), - ), + Invocation.method( + #head, + [url], + {#headers: headers}, ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #head, + [url], + {#headers: headers}, + ), + )), ) as _i3.Future<_i2.Response>); @override - _i3.Future<_i2.Response> get(Uri? url, {Map? headers}) => + _i3.Future<_i2.Response> get( + Uri? url, { + Map? headers, + }) => (super.noSuchMethod( - Invocation.method(#get, [url], {#headers: headers}), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method(#get, [url], {#headers: headers}), - ), + Invocation.method( + #get, + [url], + {#headers: headers}, ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #get, + [url], + {#headers: headers}, + ), + )), ) as _i3.Future<_i2.Response>); @override @@ -80,18 +108,24 @@ class MockClient extends _i1.Mock implements _i2.Client { Invocation.method( #post, [url], - {#headers: headers, #body: body, #encoding: encoding}, + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method( - #post, - [url], - {#headers: headers, #body: body, #encoding: encoding}, - ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #post, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - ), + )), ) as _i3.Future<_i2.Response>); @override @@ -105,18 +139,24 @@ class MockClient extends _i1.Mock implements _i2.Client { Invocation.method( #put, [url], - {#headers: headers, #body: body, #encoding: encoding}, + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method( - #put, - [url], - {#headers: headers, #body: body, #encoding: encoding}, - ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #put, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - ), + )), ) as _i3.Future<_i2.Response>); @override @@ -130,18 +170,24 @@ class MockClient extends _i1.Mock implements _i2.Client { Invocation.method( #patch, [url], - {#headers: headers, #body: body, #encoding: encoding}, + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method( - #patch, - [url], - {#headers: headers, #body: body, #encoding: encoding}, - ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #patch, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - ), + )), ) as _i3.Future<_i2.Response>); @override @@ -155,30 +201,45 @@ class MockClient extends _i1.Mock implements _i2.Client { Invocation.method( #delete, [url], - {#headers: headers, #body: body, #encoding: encoding}, + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method( - #delete, - [url], - {#headers: headers, #body: body, #encoding: encoding}, - ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #delete, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - ), + )), ) as _i3.Future<_i2.Response>); @override - _i3.Future read(Uri? url, {Map? headers}) => + _i3.Future read( + Uri? url, { + Map? headers, + }) => (super.noSuchMethod( - Invocation.method(#read, [url], {#headers: headers}), - returnValue: _i3.Future.value( - _i5.dummyValue( - this, - Invocation.method(#read, [url], {#headers: headers}), - ), + Invocation.method( + #read, + [url], + {#headers: headers}, ), + returnValue: _i3.Future.value(_i5.dummyValue( + this, + Invocation.method( + #read, + [url], + {#headers: headers}, + ), + )), ) as _i3.Future); @override @@ -187,22 +248,37 @@ class MockClient extends _i1.Mock implements _i2.Client { Map? headers, }) => (super.noSuchMethod( - Invocation.method(#readBytes, [url], {#headers: headers}), + Invocation.method( + #readBytes, + [url], + {#headers: headers}, + ), returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), ) as _i3.Future<_i6.Uint8List>); @override _i3.Future<_i2.StreamedResponse> send(_i2.BaseRequest? request) => (super.noSuchMethod( - Invocation.method(#send, [request]), - returnValue: _i3.Future<_i2.StreamedResponse>.value( - _FakeStreamedResponse_1(this, Invocation.method(#send, [request])), + Invocation.method( + #send, + [request], ), + returnValue: + _i3.Future<_i2.StreamedResponse>.value(_FakeStreamedResponse_1( + this, + Invocation.method( + #send, + [request], + ), + )), ) as _i3.Future<_i2.StreamedResponse>); @override void close() => super.noSuchMethod( - Invocation.method(#close, []), + Invocation.method( + #close, + [], + ), returnValueForMissingStub: null, ); } diff --git a/packages/ndk_flutter/analysis_options.yaml b/packages/ndk_flutter/analysis_options.yaml index 88a815bb7..ddb942a8d 100644 --- a/packages/ndk_flutter/analysis_options.yaml +++ b/packages/ndk_flutter/analysis_options.yaml @@ -1,6 +1,14 @@ analyzer: errors: experimental_member_use: ignore + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml # Additional information about this file can be found at diff --git a/packages/ndk_flutter/lib/l10n/app_en.arb b/packages/ndk_flutter/lib/l10n/app_en.arb index 96e4b575c..a26ae340c 100644 --- a/packages/ndk_flutter/lib/l10n/app_en.arb +++ b/packages/ndk_flutter/lib/l10n/app_en.arb @@ -1015,6 +1015,18 @@ "description": "Description for send by Lightning option" }, "payInvoiceTitle": "Pay Invoice", + "sendToWallet": "Send to Wallet", + "sendToWalletDescription": "Transfer to another compatible wallet", + "noCompatibleReceivingWallets": "No compatible receiving wallets", + "destinationWallet": "Destination wallet", + "walletTransferSubmitted": "Payment sent to {walletName}", + "@walletTransferSubmitted": { + "placeholders": { + "walletName": { + "type": "String" + } + } + }, "@payInvoiceTitle": { "description": "Title for pay invoice dialog" }, @@ -1496,5 +1508,58 @@ "description": "Number of restored proofs" } } - } + }, + "bolt12Wallet": "BOLT12 Wallet", + "bolt12WalletSubtitle": "Reusable Lightning offer", + "bolt12PrivateOfferSubtitle": "Reusable private offer", + "anyAmount": "Any amount", + "blindedRoute": "Blinded", + "fromAmountSats": "From {amount} sats", + "@fromAmountSats": { + "placeholders": { + "amount": { + "type": "String" + } + } + }, + "fromAmountMsats": "From {amount} msats", + "@fromAmountMsats": { + "placeholders": { + "amount": { + "type": "String" + } + } + }, + "fromCurrencyAmount": "From {amount} {currency}", + "@fromCurrencyAmount": { + "placeholders": { + "amount": { + "type": "String" + }, + "currency": { + "type": "String" + } + } + }, + "bolt12Expires": "Expires {date}", + "@bolt12Expires": { + "placeholders": { + "date": { + "type": "String" + } + } + }, + "bolt12WalletTypeTitle": "BOLT12 Offer", + "bolt12WalletTypeSubtitle": "Receive-only wallet using a reusable offer", + "addBolt12WalletTitle": "Add BOLT12 Wallet", + "enterBolt12Input": "Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.", + "bolt12Input": "BOLT12 payment target", + "bolt12InputHint": "lno1..., bitcoin:?lno=..., or user@domain.com", + "walletNameOptional": "Wallet name (optional)", + "scanBolt12QrCodeTitle": "Scan BOLT12 QR code", + "invalidBolt12QrCode": "The QR code is not a BOLT12, BIP321, or BIP353 payment target.", + "pleaseEnterBolt12Input": "Please enter a BOLT12 offer or BIP353 address.", + "bolt12WalletAdded": "BOLT12 wallet added successfully!", + "bolt12OfferTitle": "Receive with BOLT12", + "bolt12OfferInstructions": "Share this reusable offer to receive a Lightning payment." } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations.dart b/packages/ndk_flutter/lib/l10n/app_localizations.dart index b12e95fa2..2dd7197bd 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations.dart @@ -1619,6 +1619,36 @@ abstract class AppLocalizations { /// **'Pay Invoice'** String get payInvoiceTitle; + /// No description provided for @sendToWallet. + /// + /// In en, this message translates to: + /// **'Send to Wallet'** + String get sendToWallet; + + /// No description provided for @sendToWalletDescription. + /// + /// In en, this message translates to: + /// **'Transfer to another compatible wallet'** + String get sendToWalletDescription; + + /// No description provided for @noCompatibleReceivingWallets. + /// + /// In en, this message translates to: + /// **'No compatible receiving wallets'** + String get noCompatibleReceivingWallets; + + /// No description provided for @destinationWallet. + /// + /// In en, this message translates to: + /// **'Destination wallet'** + String get destinationWallet; + + /// No description provided for @walletTransferSubmitted. + /// + /// In en, this message translates to: + /// **'Payment sent to {walletName}'** + String walletTransferSubmitted(String walletName); + /// Label for invoice input /// /// In en, this message translates to: @@ -2290,6 +2320,138 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Restored {count} proofs from backup'** String restoreSuccess(int count); + + /// No description provided for @bolt12Wallet. + /// + /// In en, this message translates to: + /// **'BOLT12 Wallet'** + String get bolt12Wallet; + + /// No description provided for @bolt12WalletSubtitle. + /// + /// In en, this message translates to: + /// **'Reusable Lightning offer'** + String get bolt12WalletSubtitle; + + /// No description provided for @bolt12PrivateOfferSubtitle. + /// + /// In en, this message translates to: + /// **'Reusable private offer'** + String get bolt12PrivateOfferSubtitle; + + /// No description provided for @anyAmount. + /// + /// In en, this message translates to: + /// **'Any amount'** + String get anyAmount; + + /// No description provided for @blindedRoute. + /// + /// In en, this message translates to: + /// **'Blinded'** + String get blindedRoute; + + /// No description provided for @fromAmountSats. + /// + /// In en, this message translates to: + /// **'From {amount} sats'** + String fromAmountSats(String amount); + + /// No description provided for @fromAmountMsats. + /// + /// In en, this message translates to: + /// **'From {amount} msats'** + String fromAmountMsats(String amount); + + /// No description provided for @fromCurrencyAmount. + /// + /// In en, this message translates to: + /// **'From {amount} {currency}'** + String fromCurrencyAmount(String amount, String currency); + + /// No description provided for @bolt12Expires. + /// + /// In en, this message translates to: + /// **'Expires {date}'** + String bolt12Expires(String date); + + /// No description provided for @bolt12WalletTypeTitle. + /// + /// In en, this message translates to: + /// **'BOLT12 Offer'** + String get bolt12WalletTypeTitle; + + /// No description provided for @bolt12WalletTypeSubtitle. + /// + /// In en, this message translates to: + /// **'Receive-only wallet using a reusable offer'** + String get bolt12WalletTypeSubtitle; + + /// No description provided for @addBolt12WalletTitle. + /// + /// In en, this message translates to: + /// **'Add BOLT12 Wallet'** + String get addBolt12WalletTitle; + + /// No description provided for @enterBolt12Input. + /// + /// In en, this message translates to: + /// **'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'** + String get enterBolt12Input; + + /// No description provided for @bolt12Input. + /// + /// In en, this message translates to: + /// **'BOLT12 payment target'** + String get bolt12Input; + + /// No description provided for @bolt12InputHint. + /// + /// In en, this message translates to: + /// **'lno1..., bitcoin:?lno=..., or user@domain.com'** + String get bolt12InputHint; + + /// No description provided for @walletNameOptional. + /// + /// In en, this message translates to: + /// **'Wallet name (optional)'** + String get walletNameOptional; + + /// No description provided for @scanBolt12QrCodeTitle. + /// + /// In en, this message translates to: + /// **'Scan BOLT12 QR code'** + String get scanBolt12QrCodeTitle; + + /// No description provided for @invalidBolt12QrCode. + /// + /// In en, this message translates to: + /// **'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'** + String get invalidBolt12QrCode; + + /// No description provided for @pleaseEnterBolt12Input. + /// + /// In en, this message translates to: + /// **'Please enter a BOLT12 offer or BIP353 address.'** + String get pleaseEnterBolt12Input; + + /// No description provided for @bolt12WalletAdded. + /// + /// In en, this message translates to: + /// **'BOLT12 wallet added successfully!'** + String get bolt12WalletAdded; + + /// No description provided for @bolt12OfferTitle. + /// + /// In en, this message translates to: + /// **'Receive with BOLT12'** + String get bolt12OfferTitle; + + /// No description provided for @bolt12OfferInstructions. + /// + /// In en, this message translates to: + /// **'Share this reusable offer to receive a Lightning payment.'** + String get bolt12OfferInstructions; } 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 a94b5f2bf..d8b89327c 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_de.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_de.dart @@ -1,6 +1,5 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; - import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -772,6 +771,23 @@ class AppLocalizationsDe extends AppLocalizations { @override String get payInvoiceTitle => 'Rechnung bezahlen'; + @override + String get sendToWallet => 'Send to Wallet'; + + @override + String get sendToWalletDescription => 'Transfer to another compatible wallet'; + + @override + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Rechnung'; @@ -1136,4 +1152,83 @@ class AppLocalizationsDe extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'BOLT12 Wallet'; + + @override + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + + @override + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + + @override + String get anyAmount => 'Any amount'; + + @override + String get blindedRoute => 'Blinded'; + + @override + String fromAmountSats(String amount) { + return 'From $amount sats'; + } + + @override + String fromAmountMsats(String amount) { + return 'From $amount msats'; + } + + @override + String fromCurrencyAmount(String amount, String currency) { + return 'From $amount $currency'; + } + + @override + String bolt12Expires(String date) { + return 'Expires $date'; + } + + @override + String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + + @override + String get bolt12WalletTypeSubtitle => + 'Receive-only wallet using a reusable offer'; + + @override + String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + + @override + String get enterBolt12Input => + 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + + @override + String get bolt12Input => 'BOLT12 payment target'; + + @override + String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + + @override + String get walletNameOptional => 'Wallet name (optional)'; + + @override + String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Please enter a BOLT12 offer or BIP353 address.'; + + @override + String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + 'Share this reusable offer to receive a Lightning payment.'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_en.dart b/packages/ndk_flutter/lib/l10n/app_localizations_en.dart index b0d2e1d4a..b899b9fbd 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_en.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_en.dart @@ -1,6 +1,5 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; - import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -771,6 +770,23 @@ class AppLocalizationsEn extends AppLocalizations { @override String get payInvoiceTitle => 'Pay Invoice'; + @override + String get sendToWallet => 'Send to Wallet'; + + @override + String get sendToWalletDescription => 'Transfer to another compatible wallet'; + + @override + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Invoice'; @@ -1132,4 +1148,83 @@ class AppLocalizationsEn extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'BOLT12 Wallet'; + + @override + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + + @override + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + + @override + String get anyAmount => 'Any amount'; + + @override + String get blindedRoute => 'Blinded'; + + @override + String fromAmountSats(String amount) { + return 'From $amount sats'; + } + + @override + String fromAmountMsats(String amount) { + return 'From $amount msats'; + } + + @override + String fromCurrencyAmount(String amount, String currency) { + return 'From $amount $currency'; + } + + @override + String bolt12Expires(String date) { + return 'Expires $date'; + } + + @override + String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + + @override + String get bolt12WalletTypeSubtitle => + 'Receive-only wallet using a reusable offer'; + + @override + String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + + @override + String get enterBolt12Input => + 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + + @override + String get bolt12Input => 'BOLT12 payment target'; + + @override + String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + + @override + String get walletNameOptional => 'Wallet name (optional)'; + + @override + String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Please enter a BOLT12 offer or BIP353 address.'; + + @override + String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + 'Share this reusable offer to receive a Lightning payment.'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_es.dart b/packages/ndk_flutter/lib/l10n/app_localizations_es.dart index 10a513749..a6006e698 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_es.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_es.dart @@ -1,6 +1,5 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; - import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -774,6 +773,23 @@ class AppLocalizationsEs extends AppLocalizations { @override String get payInvoiceTitle => 'Pagar Factura'; + @override + String get sendToWallet => 'Send to Wallet'; + + @override + String get sendToWalletDescription => 'Transfer to another compatible wallet'; + + @override + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Factura'; @@ -1137,4 +1153,83 @@ class AppLocalizationsEs extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'BOLT12 Wallet'; + + @override + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + + @override + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + + @override + String get anyAmount => 'Any amount'; + + @override + String get blindedRoute => 'Blinded'; + + @override + String fromAmountSats(String amount) { + return 'From $amount sats'; + } + + @override + String fromAmountMsats(String amount) { + return 'From $amount msats'; + } + + @override + String fromCurrencyAmount(String amount, String currency) { + return 'From $amount $currency'; + } + + @override + String bolt12Expires(String date) { + return 'Expires $date'; + } + + @override + String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + + @override + String get bolt12WalletTypeSubtitle => + 'Receive-only wallet using a reusable offer'; + + @override + String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + + @override + String get enterBolt12Input => + 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + + @override + String get bolt12Input => 'BOLT12 payment target'; + + @override + String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + + @override + String get walletNameOptional => 'Wallet name (optional)'; + + @override + String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Please enter a BOLT12 offer or BIP353 address.'; + + @override + String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + 'Share this reusable offer to receive a Lightning payment.'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart b/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart index 92a607640..6a440e210 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart @@ -1,6 +1,5 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; - import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -772,6 +771,23 @@ class AppLocalizationsFi extends AppLocalizations { @override String get payInvoiceTitle => 'Maksa lasku'; + @override + String get sendToWallet => 'Send to Wallet'; + + @override + String get sendToWalletDescription => 'Transfer to another compatible wallet'; + + @override + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Lasku'; @@ -1134,4 +1150,83 @@ class AppLocalizationsFi extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'BOLT12 Wallet'; + + @override + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + + @override + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + + @override + String get anyAmount => 'Any amount'; + + @override + String get blindedRoute => 'Blinded'; + + @override + String fromAmountSats(String amount) { + return 'From $amount sats'; + } + + @override + String fromAmountMsats(String amount) { + return 'From $amount msats'; + } + + @override + String fromCurrencyAmount(String amount, String currency) { + return 'From $amount $currency'; + } + + @override + String bolt12Expires(String date) { + return 'Expires $date'; + } + + @override + String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + + @override + String get bolt12WalletTypeSubtitle => + 'Receive-only wallet using a reusable offer'; + + @override + String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + + @override + String get enterBolt12Input => + 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + + @override + String get bolt12Input => 'BOLT12 payment target'; + + @override + String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + + @override + String get walletNameOptional => 'Wallet name (optional)'; + + @override + String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Please enter a BOLT12 offer or BIP353 address.'; + + @override + String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + 'Share this reusable offer to receive a Lightning payment.'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart b/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart index 3ac67d7ca..77bcea2b5 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart @@ -1,6 +1,5 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; - import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -773,6 +772,23 @@ class AppLocalizationsFr extends AppLocalizations { @override String get payInvoiceTitle => 'Payer la Facture'; + @override + String get sendToWallet => 'Send to Wallet'; + + @override + String get sendToWalletDescription => 'Transfer to another compatible wallet'; + + @override + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Facture'; @@ -1137,4 +1153,83 @@ class AppLocalizationsFr extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'BOLT12 Wallet'; + + @override + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + + @override + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + + @override + String get anyAmount => 'Any amount'; + + @override + String get blindedRoute => 'Blinded'; + + @override + String fromAmountSats(String amount) { + return 'From $amount sats'; + } + + @override + String fromAmountMsats(String amount) { + return 'From $amount msats'; + } + + @override + String fromCurrencyAmount(String amount, String currency) { + return 'From $amount $currency'; + } + + @override + String bolt12Expires(String date) { + return 'Expires $date'; + } + + @override + String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + + @override + String get bolt12WalletTypeSubtitle => + 'Receive-only wallet using a reusable offer'; + + @override + String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + + @override + String get enterBolt12Input => + 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + + @override + String get bolt12Input => 'BOLT12 payment target'; + + @override + String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + + @override + String get walletNameOptional => 'Wallet name (optional)'; + + @override + String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Please enter a BOLT12 offer or BIP353 address.'; + + @override + String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + 'Share this reusable offer to receive a Lightning payment.'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_it.dart b/packages/ndk_flutter/lib/l10n/app_localizations_it.dart index 48a726c32..fab6c3ace 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_it.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_it.dart @@ -1,6 +1,5 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; - import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -775,6 +774,23 @@ class AppLocalizationsIt extends AppLocalizations { @override String get payInvoiceTitle => 'Paga fattura'; + @override + String get sendToWallet => 'Send to Wallet'; + + @override + String get sendToWalletDescription => 'Transfer to another compatible wallet'; + + @override + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Fattura'; @@ -1138,4 +1154,83 @@ class AppLocalizationsIt extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'BOLT12 Wallet'; + + @override + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + + @override + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + + @override + String get anyAmount => 'Any amount'; + + @override + String get blindedRoute => 'Blinded'; + + @override + String fromAmountSats(String amount) { + return 'From $amount sats'; + } + + @override + String fromAmountMsats(String amount) { + return 'From $amount msats'; + } + + @override + String fromCurrencyAmount(String amount, String currency) { + return 'From $amount $currency'; + } + + @override + String bolt12Expires(String date) { + return 'Expires $date'; + } + + @override + String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + + @override + String get bolt12WalletTypeSubtitle => + 'Receive-only wallet using a reusable offer'; + + @override + String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + + @override + String get enterBolt12Input => + 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + + @override + String get bolt12Input => 'BOLT12 payment target'; + + @override + String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + + @override + String get walletNameOptional => 'Wallet name (optional)'; + + @override + String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Please enter a BOLT12 offer or BIP353 address.'; + + @override + String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + 'Share this reusable offer to receive a Lightning payment.'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart b/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart index a014e8fe9..90dcbb34f 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart @@ -1,6 +1,5 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; - import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -765,6 +764,23 @@ class AppLocalizationsJa extends AppLocalizations { @override String get payInvoiceTitle => '請求書を支払う'; + @override + String get sendToWallet => 'Send to Wallet'; + + @override + String get sendToWalletDescription => 'Transfer to another compatible wallet'; + + @override + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => '請求書'; @@ -1123,4 +1139,83 @@ class AppLocalizationsJa extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'BOLT12 Wallet'; + + @override + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + + @override + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + + @override + String get anyAmount => 'Any amount'; + + @override + String get blindedRoute => 'Blinded'; + + @override + String fromAmountSats(String amount) { + return 'From $amount sats'; + } + + @override + String fromAmountMsats(String amount) { + return 'From $amount msats'; + } + + @override + String fromCurrencyAmount(String amount, String currency) { + return 'From $amount $currency'; + } + + @override + String bolt12Expires(String date) { + return 'Expires $date'; + } + + @override + String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + + @override + String get bolt12WalletTypeSubtitle => + 'Receive-only wallet using a reusable offer'; + + @override + String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + + @override + String get enterBolt12Input => + 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + + @override + String get bolt12Input => 'BOLT12 payment target'; + + @override + String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + + @override + String get walletNameOptional => 'Wallet name (optional)'; + + @override + String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Please enter a BOLT12 offer or BIP353 address.'; + + @override + String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + 'Share this reusable offer to receive a Lightning payment.'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart b/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart index 62e4b9428..8633cbca1 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart @@ -1,6 +1,5 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; - import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -775,6 +774,23 @@ class AppLocalizationsPl extends AppLocalizations { @override String get payInvoiceTitle => 'Zapłać fakturę'; + @override + String get sendToWallet => 'Send to Wallet'; + + @override + String get sendToWalletDescription => 'Transfer to another compatible wallet'; + + @override + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Faktura'; @@ -1136,4 +1152,83 @@ class AppLocalizationsPl extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'BOLT12 Wallet'; + + @override + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + + @override + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + + @override + String get anyAmount => 'Any amount'; + + @override + String get blindedRoute => 'Blinded'; + + @override + String fromAmountSats(String amount) { + return 'From $amount sats'; + } + + @override + String fromAmountMsats(String amount) { + return 'From $amount msats'; + } + + @override + String fromCurrencyAmount(String amount, String currency) { + return 'From $amount $currency'; + } + + @override + String bolt12Expires(String date) { + return 'Expires $date'; + } + + @override + String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + + @override + String get bolt12WalletTypeSubtitle => + 'Receive-only wallet using a reusable offer'; + + @override + String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + + @override + String get enterBolt12Input => + 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + + @override + String get bolt12Input => 'BOLT12 payment target'; + + @override + String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + + @override + String get walletNameOptional => 'Wallet name (optional)'; + + @override + String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Please enter a BOLT12 offer or BIP353 address.'; + + @override + String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + 'Share this reusable offer to receive a Lightning payment.'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart b/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart index 43190bafd..3572c560b 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart @@ -1,6 +1,5 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; - import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -776,6 +775,23 @@ class AppLocalizationsPt extends AppLocalizations { @override String get payInvoiceTitle => 'Pagar fatura'; + @override + String get sendToWallet => 'Send to Wallet'; + + @override + String get sendToWalletDescription => 'Transfer to another compatible wallet'; + + @override + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Fatura'; @@ -1140,6 +1156,85 @@ class AppLocalizationsPt extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'BOLT12 Wallet'; + + @override + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + + @override + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + + @override + String get anyAmount => 'Any amount'; + + @override + String get blindedRoute => 'Blinded'; + + @override + String fromAmountSats(String amount) { + return 'From $amount sats'; + } + + @override + String fromAmountMsats(String amount) { + return 'From $amount msats'; + } + + @override + String fromCurrencyAmount(String amount, String currency) { + return 'From $amount $currency'; + } + + @override + String bolt12Expires(String date) { + return 'Expires $date'; + } + + @override + String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + + @override + String get bolt12WalletTypeSubtitle => + 'Receive-only wallet using a reusable offer'; + + @override + String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + + @override + String get enterBolt12Input => + 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + + @override + String get bolt12Input => 'BOLT12 payment target'; + + @override + String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + + @override + String get walletNameOptional => 'Wallet name (optional)'; + + @override + String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Please enter a BOLT12 offer or BIP353 address.'; + + @override + String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + 'Share this reusable offer to receive a Lightning payment.'; } /// The translations for Portuguese, as used in Brazil (`pt_BR`). diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart b/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart index 1d7ca1ca1..7b0e4f065 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart @@ -1,6 +1,5 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; - import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -771,6 +770,23 @@ class AppLocalizationsRu extends AppLocalizations { @override String get payInvoiceTitle => 'Оплатить Счёт'; + @override + String get sendToWallet => 'Send to Wallet'; + + @override + String get sendToWalletDescription => 'Transfer to another compatible wallet'; + + @override + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Счёт'; @@ -1135,4 +1151,83 @@ class AppLocalizationsRu extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'BOLT12 Wallet'; + + @override + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + + @override + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + + @override + String get anyAmount => 'Any amount'; + + @override + String get blindedRoute => 'Blinded'; + + @override + String fromAmountSats(String amount) { + return 'From $amount sats'; + } + + @override + String fromAmountMsats(String amount) { + return 'From $amount msats'; + } + + @override + String fromCurrencyAmount(String amount, String currency) { + return 'From $amount $currency'; + } + + @override + String bolt12Expires(String date) { + return 'Expires $date'; + } + + @override + String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + + @override + String get bolt12WalletTypeSubtitle => + 'Receive-only wallet using a reusable offer'; + + @override + String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + + @override + String get enterBolt12Input => + 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + + @override + String get bolt12Input => 'BOLT12 payment target'; + + @override + String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + + @override + String get walletNameOptional => 'Wallet name (optional)'; + + @override + String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Please enter a BOLT12 offer or BIP353 address.'; + + @override + String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + 'Share this reusable offer to receive a Lightning payment.'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart b/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart index bca785aae..09d35ef49 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart @@ -1,6 +1,5 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; - import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -772,6 +771,23 @@ class AppLocalizationsSk extends AppLocalizations { @override String get payInvoiceTitle => 'Zaplatiť faktúru'; + @override + String get sendToWallet => 'Send to Wallet'; + + @override + String get sendToWalletDescription => 'Transfer to another compatible wallet'; + + @override + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Faktúra'; @@ -1134,4 +1150,83 @@ class AppLocalizationsSk extends AppLocalizations { String restoreSuccess(int count) { return 'Obnovených $count dôkazov zo zálohy'; } + + @override + String get bolt12Wallet => 'BOLT12 Wallet'; + + @override + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + + @override + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + + @override + String get anyAmount => 'Any amount'; + + @override + String get blindedRoute => 'Blinded'; + + @override + String fromAmountSats(String amount) { + return 'From $amount sats'; + } + + @override + String fromAmountMsats(String amount) { + return 'From $amount msats'; + } + + @override + String fromCurrencyAmount(String amount, String currency) { + return 'From $amount $currency'; + } + + @override + String bolt12Expires(String date) { + return 'Expires $date'; + } + + @override + String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + + @override + String get bolt12WalletTypeSubtitle => + 'Receive-only wallet using a reusable offer'; + + @override + String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + + @override + String get enterBolt12Input => + 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + + @override + String get bolt12Input => 'BOLT12 payment target'; + + @override + String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + + @override + String get walletNameOptional => 'Wallet name (optional)'; + + @override + String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Please enter a BOLT12 offer or BIP353 address.'; + + @override + String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + 'Share this reusable offer to receive a Lightning payment.'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart b/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart index b25941449..94519e3c5 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart @@ -1,6 +1,5 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; - import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -765,6 +764,23 @@ class AppLocalizationsZh extends AppLocalizations { @override String get payInvoiceTitle => '支付发票'; + @override + String get sendToWallet => 'Send to Wallet'; + + @override + String get sendToWalletDescription => 'Transfer to another compatible wallet'; + + @override + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => '发票'; @@ -1122,4 +1138,83 @@ class AppLocalizationsZh extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'BOLT12 Wallet'; + + @override + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + + @override + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + + @override + String get anyAmount => 'Any amount'; + + @override + String get blindedRoute => 'Blinded'; + + @override + String fromAmountSats(String amount) { + return 'From $amount sats'; + } + + @override + String fromAmountMsats(String amount) { + return 'From $amount msats'; + } + + @override + String fromCurrencyAmount(String amount, String currency) { + return 'From $amount $currency'; + } + + @override + String bolt12Expires(String date) { + return 'Expires $date'; + } + + @override + String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + + @override + String get bolt12WalletTypeSubtitle => + 'Receive-only wallet using a reusable offer'; + + @override + String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + + @override + String get enterBolt12Input => + 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + + @override + String get bolt12Input => 'BOLT12 payment target'; + + @override + String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + + @override + String get walletNameOptional => 'Wallet name (optional)'; + + @override + String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Please enter a BOLT12 offer or BIP353 address.'; + + @override + String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + 'Share this reusable offer to receive a Lightning payment.'; } diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_add_wallet_dialogs.dart b/packages/ndk_flutter/lib/widgets/wallets/n_add_wallet_dialogs.dart index f33a164a0..7036cac48 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_add_wallet_dialogs.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_add_wallet_dialogs.dart @@ -22,6 +22,9 @@ const String _dialogBackResult = '__back__'; /// Opens a host-provided NWC QR scanner and returns the scanned URI. typedef NwcUriScanner = Future Function(BuildContext context); +/// Opens a host-provided scanner and returns a BOLT12, BIP321, or BIP353 input. +typedef Bolt12InputScanner = Future Function(BuildContext context); + enum AlbyGoConnectMethod { walletAuth, nostrNwcCallback } const List _defaultAlbyGoRequestMethods = [ @@ -968,7 +971,219 @@ class _AddLnurlWalletDialogState extends State<_AddLnurlWalletDialog> { } } -/// Shows a dialog to choose wallet type (Cashu, NWC, or LNURL). +/// Shows a dialog to add a receive-only BOLT12 offer wallet. +Future showAddBolt12WalletDialog( + BuildContext context, + NdkFlutter ndkFlutter, { + bool returnToWalletType = false, + AlbyGoConnectConfig albyGoConnectConfig = kDefaultAlbyGoConnectConfig, + NwcWalletAuthCoordinator? nwcWalletAuthCoordinator, + NwcUriScanner? nwcUriScanner, + Bolt12InputScanner? bolt12InputScanner, +}) { + return showDialog( + context: context, + builder: (dialogContext) => _AddBolt12WalletDialog( + ndkFlutter: ndkFlutter, + parentContext: context, + returnToWalletType: returnToWalletType, + albyGoConnectConfig: albyGoConnectConfig, + nwcWalletAuthCoordinator: nwcWalletAuthCoordinator, + nwcUriScanner: nwcUriScanner, + bolt12InputScanner: bolt12InputScanner, + ), + ); +} + +class _AddBolt12WalletDialog extends StatefulWidget { + final NdkFlutter ndkFlutter; + final BuildContext parentContext; + final bool returnToWalletType; + final AlbyGoConnectConfig albyGoConnectConfig; + final NwcWalletAuthCoordinator? nwcWalletAuthCoordinator; + final NwcUriScanner? nwcUriScanner; + final Bolt12InputScanner? bolt12InputScanner; + + const _AddBolt12WalletDialog({ + required this.ndkFlutter, + required this.parentContext, + required this.returnToWalletType, + required this.albyGoConnectConfig, + required this.nwcWalletAuthCoordinator, + required this.nwcUriScanner, + required this.bolt12InputScanner, + }); + + @override + State<_AddBolt12WalletDialog> createState() => _AddBolt12WalletDialogState(); +} + +class _AddBolt12WalletDialogState extends State<_AddBolt12WalletDialog> { + final _inputController = TextEditingController(); + final _nameController = TextEditingController(); + bool _isLoading = false; + + @override + void dispose() { + _inputController.dispose(); + _nameController.dispose(); + super.dispose(); + } + + Future _scan() async { + final scanner = widget.bolt12InputScanner; + if (scanner == null) return; + final value = await scanner(context); + if (!mounted || value == null) return; + + if (Bolt12WalletProvider.isSupportedInput(value)) { + _inputController.text = value.trim(); + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.invalidBolt12QrCode), + backgroundColor: Colors.red, + ), + ); + } + + Future _add() async { + final l10n = AppLocalizations.of(context)!; + final scaffoldMessenger = ScaffoldMessenger.of(context); + final input = _inputController.text.trim(); + if (input.isEmpty) { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text(l10n.pleaseEnterBolt12Input), + backgroundColor: Colors.red, + ), + ); + return; + } + + setState(() => _isLoading = true); + try { + final resolved = await Bolt12WalletProvider.resolveInput(input); + final requestedName = _nameController.text.trim(); + final description = resolved.decoded['offer_description'] as String?; + final name = requestedName.isNotEmpty + ? requestedName + : resolved.bip353Address ?? description ?? l10n.bolt12Wallet; + final wallet = + widget.ndkFlutter.ndk.wallets.createWallet( + id: 'bolt12-${DateTime.now().microsecondsSinceEpoch}', + name: name, + type: WalletType.BOLT12, + supportedUnits: {'sat'}, + metadata: resolved.toMetadata(), + ) + as Bolt12Wallet; + await widget.ndkFlutter.ndk.wallets.addWallet(wallet); + + if (!mounted) return; + Navigator.of(context).pop(wallet); + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text(l10n.bolt12WalletAdded), + backgroundColor: Colors.green, + ), + ); + } catch (error) { + scaffoldMessenger.showSnackBar( + SnackBar(content: Text(error.toString()), backgroundColor: Colors.red), + ); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return AlertDialog( + title: Row( + children: [ + IconButton( + onPressed: () async { + Navigator.of(context).pop(); + if (widget.returnToWalletType && widget.parentContext.mounted) { + await showAddWalletTypeDialog( + widget.parentContext, + widget.ndkFlutter, + albyGoConnectConfig: widget.albyGoConnectConfig, + nwcWalletAuthCoordinator: widget.nwcWalletAuthCoordinator, + nwcUriScanner: widget.nwcUriScanner, + bolt12InputScanner: widget.bolt12InputScanner, + ); + } + }, + icon: const Icon(Icons.arrow_back), + ), + Expanded(child: Text(l10n.addBolt12WalletTitle)), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + content: SizedBox( + width: 520, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.enterBolt12Input), + const SizedBox(height: 16), + TextField( + controller: _inputController, + minLines: 2, + maxLines: 4, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.bolt12Input, + hintText: l10n.bolt12InputHint, + suffixIcon: widget.bolt12InputScanner == null + ? null + : IconButton( + onPressed: _scan, + icon: const Icon(Icons.qr_code_scanner), + tooltip: l10n.scanBolt12QrCodeTitle, + ), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _nameController, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.walletNameOptional, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + TextButton( + onPressed: _isLoading ? null : _add, + child: _isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.add), + ), + ], + ); + } +} + +/// Shows a dialog to choose wallet type. /// /// Returns true if a wallet type was selected, false if cancelled. /// Use [albyGoConnectConfig] to override Alby Go app metadata. @@ -978,6 +1193,7 @@ Future showAddWalletTypeDialog( AlbyGoConnectConfig albyGoConnectConfig = kDefaultAlbyGoConnectConfig, NwcWalletAuthCoordinator? nwcWalletAuthCoordinator, NwcUriScanner? nwcUriScanner, + Bolt12InputScanner? bolt12InputScanner, }) async { final l10n = AppLocalizations.of(context)!; @@ -1057,6 +1273,25 @@ Future showAddWalletTypeDialog( }, ), const SizedBox(height: 12), + _WalletTypeListOption( + icon: Icons.electric_bolt, + title: l10n.bolt12WalletTypeTitle, + subtitle: l10n.bolt12WalletTypeSubtitle, + infoUrl: 'https://bolt12.org/', + onTap: () async { + Navigator.of(dialogContext).pop(true); + await showAddBolt12WalletDialog( + context, + ndkFlutter, + returnToWalletType: true, + albyGoConnectConfig: albyGoConnectConfig, + nwcWalletAuthCoordinator: nwcWalletAuthCoordinator, + nwcUriScanner: nwcUriScanner, + bolt12InputScanner: bolt12InputScanner, + ); + }, + ), + const SizedBox(height: 12), _WalletTypeListOption( imageAsset: 'assets/images/cashu.png', title: l10n.cashuWalletTypeTitle, 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 a2d9696b8..9eddb8186 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_actions.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_actions.dart @@ -64,6 +64,7 @@ class _NWalletActionsState extends State final bool isCashu = wallet is CashuWallet; final bool isNwc = wallet is NwcWallet; + final bool isBolt12 = wallet is Bolt12Wallet; final bool canSend = wallet.canSend; final bool canReceive = wallet.canReceive; final bool condensed = widget.condensed; @@ -101,6 +102,8 @@ class _NWalletActionsState extends State return const Icon(Icons.cloud, color: Colors.blue); }, ) + else if (isBolt12) + const Icon(Icons.electric_bolt, color: Colors.green) else const Icon(Icons.bolt, color: Colors.purple), const SizedBox(width: 8), @@ -109,6 +112,8 @@ class _NWalletActionsState extends State ? l10n.cashuWallet : isNwc ? l10n.nwcWallet + : isBolt12 + ? l10n.bolt12Wallet : l10n.lnurlWallet, style: Theme.of(context).textTheme.titleMedium, ), 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 11ced4ef2..d3f26a9d9 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card.dart @@ -58,6 +58,9 @@ class NWalletCard extends StatefulWidget { /// Custom icon configuration for LNURL wallets final WalletIconConfig? lnurlIcon; + /// Custom icon configuration for BOLT12 wallets + final WalletIconConfig? bolt12Icon; + const NWalletCard({ super.key, required this.wallet, @@ -73,6 +76,7 @@ class NWalletCard extends StatefulWidget { this.cashuIcon, this.nwcIcon, this.lnurlIcon, + this.bolt12Icon, }); @override @@ -247,6 +251,7 @@ class _NWalletCardState extends State final bool isCashu = widget.wallet is CashuWallet; final bool isNwc = widget.wallet is NwcWallet; final bool isLnurl = widget.wallet is LnurlWallet; + final bool isBolt12 = widget.wallet is Bolt12Wallet; final nwcPermissions = isNwc ? _nwcPermissions(widget.wallet as NwcWallet) : const {}; @@ -267,6 +272,8 @@ class _NWalletCardState extends State walletName = (widget.wallet as NwcWallet).name; } else if (isLnurl) { walletName = (widget.wallet as LnurlWallet).name; + } else if (isBolt12) { + walletName = (widget.wallet as Bolt12Wallet).name; } else { walletName = l10n.unknownWalletType; } @@ -284,6 +291,14 @@ class _NWalletCardState extends State subtitle = lnurlWallet.identifier == lnurlWallet.name ? '' : lnurlWallet.identifier; + } else if (isBolt12) { + final bolt12Wallet = widget.wallet as Bolt12Wallet; + subtitle = + _nonEmpty(bolt12Wallet.bip353Address) ?? + _nonEmpty(bolt12Wallet.issuer) ?? + (bolt12Wallet.hasBlindedPaths + ? l10n.bolt12PrivateOfferSubtitle + : l10n.bolt12WalletSubtitle); } else { subtitle = ''; } @@ -303,7 +318,12 @@ class _NWalletCardState extends State .toColor(); gradientColors = [color, lighterColor]; } else { - gradientColors = _getDefaultGradientColors(isCashu, isNwc, isLnurl); + gradientColors = _getDefaultGradientColors( + isCashu, + isNwc, + isLnurl, + isBolt12, + ); } } final Color shadowColor = gradientColors[0]; @@ -324,6 +344,10 @@ class _NWalletCardState extends State iconConfig = widget.lnurlIcon ?? const WalletIconConfig(); defaultAssetName = null; // LNURL uses bolt icon, not PNG fallbackIcon = Icons.bolt; + } else if (isBolt12) { + iconConfig = widget.bolt12Icon ?? const WalletIconConfig(); + defaultAssetName = null; + fallbackIcon = Icons.electric_bolt; } else { iconConfig = const WalletIconConfig(); defaultAssetName = 'wallet.png'; @@ -478,6 +502,11 @@ class _NWalletCardState extends State context, widget.wallet as LnurlWallet, ) + : isBolt12 + ? _buildBolt12Info( + context, + widget.wallet as Bolt12Wallet, + ) : (canShowNwcBalance ? _buildBalance(context) : const SizedBox.shrink()), @@ -805,6 +834,7 @@ class _NWalletCardState extends State bool isCashu, bool isNwc, bool isLnurl, + bool isBolt12, ) { if (isCashu) { return [const Color(0xFF7F38CA), const Color(0xFF9B5AD8)]; @@ -815,6 +845,8 @@ class _NWalletCardState extends State ]; } else if (isLnurl) { return [const Color(0xFFFFB300), const Color(0xFFFFC107)]; + } else if (isBolt12) { + return [const Color(0xFF1B5E20), const Color(0xFF43A047)]; } else { return [Colors.grey[700]!, Colors.grey[400]!]; } @@ -1018,6 +1050,26 @@ class _NWalletCardState extends State metadataFetchedAt: w.metadataFetchedAt, metadata: updatedMetadata, ); + } else if (widget.wallet is Bolt12Wallet) { + final w = widget.wallet as Bolt12Wallet; + updatedWallet = Bolt12Wallet( + id: w.id, + name: w.name, + supportedUnits: w.supportedUnits, + offer: w.offer, + source: w.source, + bip353Address: w.bip353Address, + description: w.description, + nodeId: w.nodeId, + offerId: w.offerId, + amount: w.amount, + issuer: w.issuer, + currency: w.currency, + expiresAt: w.expiresAt, + quantityMax: w.quantityMax, + hasBlindedPaths: w.hasBlindedPaths, + metadata: updatedMetadata, + ); } else { throw UnsupportedError('Unknown wallet type'); } @@ -1068,6 +1120,79 @@ class _NWalletCardState extends State ); } + Widget _buildBolt12Info(BuildContext context, Bolt12Wallet wallet) { + final l10n = AppLocalizations.of(context)!; + final description = _nonEmpty(wallet.description); + final summary = [ + l10n.receiveOnlyWallet, + _formatBolt12Amount(context, wallet), + if (wallet.hasBlindedPaths) l10n.blindedRoute, + if (wallet.expiresAt != null) + l10n.bolt12Expires( + DateFormat.yMd(Localizations.localeOf(context).toString()).format( + DateTime.fromMillisecondsSinceEpoch( + wallet.expiresAt! * 1000, + isUtc: true, + ).toLocal(), + ), + ), + ].join(' · '); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + description ?? _shortBolt12Offer(wallet.offer), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + Text( + summary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: Colors.white.withAlpha(200), fontSize: 12), + ), + ], + ); + } + + String _formatBolt12Amount(BuildContext context, Bolt12Wallet wallet) { + final l10n = AppLocalizations.of(context)!; + final rawAmount = _nonEmpty(wallet.amount); + final amount = rawAmount == null ? null : int.tryParse(rawAmount); + if (rawAmount == null || amount == 0) return l10n.anyAmount; + + final formatter = NumberFormat.decimalPattern( + Localizations.localeOf(context).toString(), + ); + final formattedAmount = amount == null + ? rawAmount + : formatter.format(amount); + final currency = _nonEmpty(wallet.currency); + if (currency != null) { + return l10n.fromCurrencyAmount(formattedAmount, currency.toUpperCase()); + } + if (amount != null && amount % 1000 == 0) { + return l10n.fromAmountSats(formatter.format(amount ~/ 1000)); + } + return l10n.fromAmountMsats(formattedAmount); + } + + String _shortBolt12Offer(String offer) { + if (offer.length <= 18) return offer; + return '${offer.substring(0, 9)}…${offer.substring(offer.length - 6)}'; + } + + String? _nonEmpty(String? value) { + final normalized = value?.trim(); + return normalized == null || normalized.isEmpty ? null : normalized; + } + Widget _buildBalance(BuildContext context) { final l10n = AppLocalizations.of(context)!; final numberFormatter = NumberFormat.decimalPattern( diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card_list.dart b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card_list.dart index 1478077fb..583f92c19 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card_list.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card_list.dart @@ -29,6 +29,9 @@ class NWalletCardList extends StatefulWidget { /// Custom icon configuration for LNURL wallets final WalletIconConfig? lnurlIcon; + /// Custom icon configuration for BOLT12 wallets + final WalletIconConfig? bolt12Icon; + /// Whether to show the add-wallet template card. final bool showAddWalletCard; @@ -44,6 +47,7 @@ class NWalletCardList extends StatefulWidget { this.cashuIcon, this.nwcIcon, this.lnurlIcon, + this.bolt12Icon, this.showAddWalletCard = true, }); @@ -135,6 +139,26 @@ class _NWalletCardListState extends State { metadata: metadata, ); } + if (wallet is Bolt12Wallet) { + return Bolt12Wallet( + id: wallet.id, + name: wallet.name, + supportedUnits: wallet.supportedUnits, + offer: wallet.offer, + source: wallet.source, + bip353Address: wallet.bip353Address, + description: wallet.description, + nodeId: wallet.nodeId, + offerId: wallet.offerId, + amount: wallet.amount, + issuer: wallet.issuer, + currency: wallet.currency, + expiresAt: wallet.expiresAt, + quantityMax: wallet.quantityMax, + hasBlindedPaths: wallet.hasBlindedPaths, + metadata: metadata, + ); + } throw UnsupportedError('Unknown wallet type'); } @@ -253,6 +277,7 @@ class _NWalletCardListState extends State { cashuIcon: widget.cashuIcon, nwcIcon: widget.nwcIcon, lnurlIcon: widget.lnurlIcon, + bolt12Icon: widget.bolt12Icon, ), ); }, diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_wallets.dart b/packages/ndk_flutter/lib/widgets/wallets/n_wallets.dart index c080b4e50..227ea1ab6 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_wallets.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_wallets.dart @@ -67,6 +67,9 @@ class NWallets extends StatefulWidget { /// Optional host-provided scanner for NWC QR codes. final NwcUriScanner? nwcUriScanner; + /// Optional host-provided scanner for BOLT12/BIP321/BIP353 QR codes. + final Bolt12InputScanner? bolt12InputScanner; + /// Custom icon configuration for Cashu wallets final WalletIconConfig? cashuIcon; @@ -76,6 +79,9 @@ class NWallets extends StatefulWidget { /// Custom icon configuration for LNURL wallets final WalletIconConfig? lnurlIcon; + /// Custom icon configuration for BOLT12 wallets + final WalletIconConfig? bolt12Icon; + const NWallets({ super.key, required this.ndkFlutter, @@ -98,9 +104,11 @@ class NWallets extends StatefulWidget { this.albyGoConnectConfig = kDefaultAlbyGoConnectConfig, this.nwcWalletAuthCoordinator, this.nwcUriScanner, + this.bolt12InputScanner, this.cashuIcon, this.nwcIcon, this.lnurlIcon, + this.bolt12Icon, }); @override @@ -181,6 +189,7 @@ class NWalletsState extends State { cashuIcon: widget.cashuIcon, nwcIcon: widget.nwcIcon, lnurlIcon: widget.lnurlIcon, + bolt12Icon: widget.bolt12Icon, ), ), ], @@ -216,6 +225,7 @@ class NWalletsState extends State { cashuIcon: widget.cashuIcon, nwcIcon: widget.nwcIcon, lnurlIcon: widget.lnurlIcon, + bolt12Icon: widget.bolt12Icon, ), ), if (showActionsSection) ...[ @@ -269,6 +279,7 @@ class NWalletsState extends State { albyGoConnectConfig: widget.albyGoConnectConfig, nwcWalletAuthCoordinator: _nwcWalletAuthCoordinator, nwcUriScanner: widget.nwcUriScanner, + bolt12InputScanner: widget.bolt12InputScanner, ); } } diff --git a/packages/ndk_flutter/lib/widgets/wallets/wallet_action_dialogs.dart b/packages/ndk_flutter/lib/widgets/wallets/wallet_action_dialogs.dart index 752b814a5..6c3feb23f 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/wallet_action_dialogs.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/wallet_action_dialogs.dart @@ -6,6 +6,8 @@ import 'package:pretty_qr_code/pretty_qr_code.dart'; import '../../l10n/app_localizations.dart'; +enum _WalletSendAction { token, invoice, transfer } + /// 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]. @@ -167,13 +169,83 @@ mixin WalletActionDialogsMixin on State { /// Receive flow that picks the right dialog per wallet type. void showReceiveFlow(BuildContext context, Wallet wallet) { - if (wallet is NwcWallet || wallet is LnurlWallet) { + if (wallet is Bolt12Wallet) { + _showBolt12OfferDialog(context, wallet); + } else if (wallet is NwcWallet || wallet is LnurlWallet) { _showCreateInvoiceDialog(context, wallet); } else { _showReceiveDialog(context, wallet); } } + void _showBolt12OfferDialog(BuildContext context, Bolt12Wallet wallet) { + final l10n = AppLocalizations.of(context)!; + final scaffoldMessenger = ScaffoldMessenger.of(context); + + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.bolt12OfferTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.bolt12OfferInstructions), + const SizedBox(height: 12), + SizedBox( + width: 220, + child: PrettyQrView.data( + data: wallet.offer.toUpperCase(), + errorCorrectLevel: QrErrorCorrectLevel.M, + decoration: const PrettyQrDecoration( + quietZone: PrettyQrQuietZone.standard, + background: Colors.white, + shape: PrettyQrSmoothSymbol( + color: Colors.black, + roundFactor: 0.3, + ), + ), + ), + ), + const SizedBox(height: 12), + Container( + constraints: const BoxConstraints(maxHeight: 120), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(8), + ), + child: SingleChildScrollView( + child: SelectableText( + wallet.offer, + style: const TextStyle(fontSize: 11, fontFamily: 'monospace'), + ), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(l10n.close), + ), + TextButton.icon( + onPressed: () async { + await Clipboard.setData(ClipboardData(text: wallet.offer)); + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text(l10n.copied), + backgroundColor: Colors.green, + ), + ); + }, + icon: const Icon(Icons.copy), + label: Text(l10n.copy), + ), + ], + ), + ); + } + /// Reclaims all reclaimable pending funding transactions of [wallet]. Future showReclaimPending( BuildContext context, @@ -376,15 +448,28 @@ mixin WalletActionDialogsMixin on State { ); } - void showSendDialog(BuildContext context, Wallet wallet) { + Future showSendDialog(BuildContext context, Wallet wallet) async { final l10n = AppLocalizations.of(context)!; - showModalBottomSheet( + final wallets = await ndkFlutter.ndk.wallets.getWallets(); + if (!context.mounted) return; + final destinations = wallets + .where( + (destination) => + ndkFlutter.ndk.wallets.compatibleTransferProtocol( + source: wallet, + destination: destination, + ) != + null, + ) + .toList(); + + final action = await showModalBottomSheet<_WalletSendAction>( context: context, isScrollControlled: true, - builder: (context) { + builder: (sheetContext) { return Padding( padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, + bottom: MediaQuery.of(sheetContext).viewInsets.bottom, left: 16, right: 16, top: 16, @@ -395,7 +480,7 @@ mixin WalletActionDialogsMixin on State { children: [ Text( l10n.sendOptionsTitle, - style: Theme.of(context).textTheme.headlineSmall, + style: Theme.of(sheetContext).textTheme.headlineSmall, ), const SizedBox(height: 16), if (wallet is CashuWallet) ...[ @@ -403,36 +488,248 @@ mixin WalletActionDialogsMixin on State { leading: const Icon(Icons.receipt), title: Text(l10n.sendByToken), subtitle: Text(l10n.sendByTokenDescription), - onTap: () { - Navigator.pop(context); - _showSendTokenDialog(context, wallet); - }, + onTap: () => + Navigator.pop(sheetContext, _WalletSendAction.token), ), ListTile( leading: const Icon(Icons.flash_on), title: Text(l10n.sendByLightning), subtitle: Text(l10n.sendByLightningDescription), - onTap: () { - Navigator.pop(context); - _showPayInvoiceDialog(context, wallet); - }, + onTap: () => + Navigator.pop(sheetContext, _WalletSendAction.invoice), ), ] else if (wallet is NwcWallet) ...[ ListTile( leading: const Icon(Icons.flash_on), title: Text(l10n.payInvoiceTitle), - onTap: () { - Navigator.pop(context); - _showPayInvoiceDialog(context, wallet); - }, + onTap: () => + Navigator.pop(sheetContext, _WalletSendAction.invoice), ), ], + ListTile( + enabled: destinations.isNotEmpty, + leading: const Icon(Icons.swap_horiz), + title: Text(l10n.sendToWallet), + subtitle: Text( + destinations.isEmpty + ? l10n.noCompatibleReceivingWallets + : l10n.sendToWalletDescription, + ), + onTap: destinations.isEmpty + ? null + : () => Navigator.pop( + sheetContext, + _WalletSendAction.transfer, + ), + ), const SizedBox(height: 16), ], ), ); }, ); + + if (!context.mounted || action == null) return; + switch (action) { + case _WalletSendAction.token: + _showSendTokenDialog(context, wallet as CashuWallet); + case _WalletSendAction.invoice: + _showPayInvoiceDialog(context, wallet); + case _WalletSendAction.transfer: + await _showWalletTransferDialog(context, wallet, destinations); + } + } + + Future _showWalletTransferDialog( + BuildContext context, + Wallet source, + List destinations, + ) async { + final l10n = AppLocalizations.of(context)!; + final amountController = TextEditingController(); + var selectedDestination = destinations.first; + var sending = false; + + await showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setDialogState) { + final offerAmount = _bolt12OfferAmount(selectedDestination); + return AlertDialog( + title: Text(l10n.sendToWallet), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField( + initialValue: selectedDestination.id, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.destinationWallet, + ), + isExpanded: true, + items: [ + for (final destination in destinations) + DropdownMenuItem( + value: destination.id, + child: Text( + _walletDisplayName(l10n, destination), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + onChanged: sending + ? null + : (walletId) { + if (walletId == null) return; + setDialogState(() { + selectedDestination = destinations.firstWhere( + (wallet) => wallet.id == walletId, + ); + amountController.clear(); + }); + }, + ), + const SizedBox(height: 16), + if (offerAmount != null) + InputDecorator( + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.amount, + ), + child: Text( + _bolt12OfferAmountLabel(selectedDestination, offerAmount), + ), + ) + else + TextField( + controller: amountController, + enabled: !sending, + keyboardType: TextInputType.number, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.amount, + suffixText: l10n.sats, + hintText: l10n.amountHint, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: sending + ? null + : () => Navigator.of(dialogContext).pop(), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: sending + ? null + : () async { + final fixedOfferAmount = _bolt12OfferAmount( + selectedDestination, + ); + final int? amountMsat; + if (fixedOfferAmount != null) { + // The offer already defines its amount. Omitting the + // pay parameter avoids conflicting with it. + amountMsat = null; + } else { + final amountSats = int.tryParse( + amountController.text.trim(), + ); + if (amountSats == null || amountSats <= 0) { + displayError(l10n.pleaseEnterValidAmount); + return; + } + amountMsat = amountSats * 1000; + } + + setDialogState(() => sending = true); + try { + await ndkFlutter.ndk.wallets.transfer( + sourceWalletId: source.id, + destinationWalletId: selectedDestination.id, + amountMsat: amountMsat, + ); + if (!mounted || !dialogContext.mounted) return; + Navigator.of(dialogContext).pop(); + displaySuccess( + l10n.walletTransferSubmitted( + _walletDisplayName(l10n, selectedDestination), + ), + ); + } catch (error) { + if (!mounted || !dialogContext.mounted) return; + setDialogState(() => sending = false); + displayError(error.toString()); + } + }, + child: sending + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.send), + ), + ], + ); + }, + ), + ); + amountController.dispose(); + } + + int? _bolt12OfferAmount(Wallet wallet) { + if (wallet is! Bolt12Wallet) return null; + final amount = int.tryParse(wallet.amount ?? ''); + return amount != null && amount > 0 ? amount : null; + } + + String _bolt12OfferAmountLabel(Wallet wallet, int amount) { + if (wallet is Bolt12Wallet && wallet.currency?.isNotEmpty == true) { + return '$amount ${wallet.currency!.toUpperCase()}'; + } + if (amount % 1000 == 0) return '${amount ~/ 1000} sats'; + return '$amount msats'; + } + + String _walletDisplayName(AppLocalizations l10n, Wallet wallet) { + final name = wallet.name.trim(); + if (name.isNotEmpty) return name; + + if (wallet is CashuWallet) { + final mintName = wallet.mintInfo.name?.trim(); + if (mintName?.isNotEmpty == true) return mintName!; + final mintUri = Uri.tryParse(wallet.mintUrl); + if (mintUri?.host.isNotEmpty == true) return mintUri!.host; + return '${l10n.cashuWallet} · ${_shortWalletIdentifier(wallet.id)}'; + } + if (wallet is LnurlWallet) { + final identifier = wallet.identifier.trim(); + if (identifier.isNotEmpty) return identifier; + return '${l10n.lnurlWallet} · ${_shortWalletIdentifier(wallet.id)}'; + } + if (wallet is Bolt12Wallet) { + final bip353Address = wallet.bip353Address?.trim(); + if (bip353Address?.isNotEmpty == true) return bip353Address!; + final issuer = wallet.issuer?.trim(); + if (issuer?.isNotEmpty == true) return issuer!; + return '${l10n.bolt12Wallet} · ${_shortWalletIdentifier(wallet.offer)}'; + } + if (wallet is NwcWallet) { + return '${l10n.nwcWallet} · ${_shortWalletIdentifier(wallet.id)}'; + } + return _shortWalletIdentifier(wallet.id); + } + + String _shortWalletIdentifier(String value) { + final normalized = value.trim(); + if (normalized.isEmpty) return '—'; + if (normalized.length <= 18) return normalized; + return '${normalized.substring(0, 9)}…' + '${normalized.substring(normalized.length - 6)}'; } void _showReceiveDialog(BuildContext context, Wallet wallet) { diff --git a/packages/sample-app/analysis_options.yaml b/packages/sample-app/analysis_options.yaml index 0d2902135..bf8d42185 100644 --- a/packages/sample-app/analysis_options.yaml +++ b/packages/sample-app/analysis_options.yaml @@ -7,6 +7,15 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml linter: diff --git a/packages/sample-app/lib/bolt12_qr_scanner.dart b/packages/sample-app/lib/bolt12_qr_scanner.dart new file mode 100644 index 000000000..866ce43bd --- /dev/null +++ b/packages/sample-app/lib/bolt12_qr_scanner.dart @@ -0,0 +1,175 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:ndk/entities.dart'; +import 'package:ndk_flutter/l10n/app_localizations.dart' as ndk_l10n; + +Future scanBolt12Input(BuildContext context) { + return showDialog( + context: context, + builder: (context) => const _Bolt12QrScannerDialog(), + ); +} + +class _Bolt12QrScannerDialog extends StatefulWidget { + const _Bolt12QrScannerDialog(); + + @override + State<_Bolt12QrScannerDialog> createState() => _Bolt12QrScannerDialogState(); +} + +class _Bolt12QrScannerDialogState extends State<_Bolt12QrScannerDialog> { + MobileScannerController? _controller; + bool _hasScanned = false; + String? _error; + + bool get _hasCamera => + !kIsWeb && + (defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS); + + @override + void initState() { + super.initState(); + if (_hasCamera) { + _controller = MobileScannerController( + detectionSpeed: DetectionSpeed.normal, + facing: CameraFacing.back, + ); + } + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + bool _accept(String? rawValue) { + if (rawValue == null) return false; + final value = rawValue.trim(); + if (!Bolt12WalletProvider.isSupportedInput(value)) return false; + _hasScanned = true; + Navigator.of(context).pop(value); + return true; + } + + void _onDetect(BarcodeCapture capture) { + if (_hasScanned) return; + for (final barcode in capture.barcodes) { + if (_accept(barcode.rawValue)) return; + } + setState(() { + _error = ndk_l10n.AppLocalizations.of(context)!.invalidBolt12QrCode; + }); + } + + Future _paste() async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + if (!mounted || _accept(data?.text)) return; + setState(() { + _error = ndk_l10n.AppLocalizations.of(context)!.invalidBolt12QrCode; + }); + } + + @override + Widget build(BuildContext context) { + final l10n = ndk_l10n.AppLocalizations.of(context)!; + return Dialog( + backgroundColor: Colors.black, + child: SizedBox( + width: 400, + height: _hasCamera ? 560 : 240, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.arrow_back, color: Colors.white), + ), + Expanded( + child: Text( + l10n.scanBolt12QrCodeTitle, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + ), + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, color: Colors.white), + ), + ], + ), + ), + Expanded( + child: Stack( + children: [ + if (_hasCamera) + MobileScanner(controller: _controller!, onDetect: _onDetect) + else + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + l10n.cameraNotAvailable, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70), + ), + ), + ), + if (_hasCamera) + Center( + child: Container( + width: 250, + height: 250, + decoration: BoxDecoration( + border: Border.all(color: Colors.white, width: 2), + borderRadius: BorderRadius.circular(12), + ), + ), + ), + if (_error != null) + Positioned( + top: 20, + left: 20, + right: 20, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _error!, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white), + ), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: ElevatedButton.icon( + onPressed: _paste, + icon: const Icon(Icons.paste), + label: Text(l10n.paste), + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 48), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/sample-app/lib/l10n/generated/sample_app_localizations.dart b/packages/sample-app/lib/l10n/generated/sample_app_localizations.dart index 9582cebc1..0b55dc213 100644 --- a/packages/sample-app/lib/l10n/generated/sample_app_localizations.dart +++ b/packages/sample-app/lib/l10n/generated/sample_app_localizations.dart @@ -76,9 +76,7 @@ abstract class SampleAppLocalizations { static SampleAppLocalizations? of(BuildContext context) { return Localizations.of( - context, - SampleAppLocalizations, - ); + context, SampleAppLocalizations); } static const LocalizationsDelegate delegate = @@ -112,7 +110,7 @@ abstract class SampleAppLocalizations { Locale('ja'), Locale('pl'), Locale('ru'), - Locale('zh'), + Locale('zh') ]; /// No description provided for @appName. @@ -801,8 +799,7 @@ class _SampleAppLocalizationsDelegate @override Future load(Locale locale) { return SynchronousFuture( - lookupSampleAppLocalizations(locale), - ); + lookupSampleAppLocalizations(locale)); } @override @@ -815,7 +812,7 @@ class _SampleAppLocalizationsDelegate 'ja', 'pl', 'ru', - 'zh', + 'zh' ].contains(locale.languageCode); @override @@ -846,9 +843,8 @@ SampleAppLocalizations lookupSampleAppLocalizations(Locale locale) { } throw FlutterError( - 'SampleAppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' - 'an issue with the localizations generation tool. Please file an issue ' - 'on GitHub with a reproducible sample app and the gen-l10n configuration ' - 'that was used.', - ); + 'SampleAppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.'); } diff --git a/packages/sample-app/lib/l10n/generated/sample_app_localizations_de.dart b/packages/sample-app/lib/l10n/generated/sample_app_localizations_de.dart index bf548fba3..55c184b6a 100644 --- a/packages/sample-app/lib/l10n/generated/sample_app_localizations_de.dart +++ b/packages/sample-app/lib/l10n/generated/sample_app_localizations_de.dart @@ -1,6 +1,5 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; - import 'sample_app_localizations.dart'; // ignore_for_file: type=lint diff --git a/packages/sample-app/lib/l10n/generated/sample_app_localizations_en.dart b/packages/sample-app/lib/l10n/generated/sample_app_localizations_en.dart index e80e2460d..34212df51 100644 --- a/packages/sample-app/lib/l10n/generated/sample_app_localizations_en.dart +++ b/packages/sample-app/lib/l10n/generated/sample_app_localizations_en.dart @@ -1,6 +1,5 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; - import 'sample_app_localizations.dart'; // ignore_for_file: type=lint diff --git a/packages/sample-app/lib/l10n/generated/sample_app_localizations_es.dart b/packages/sample-app/lib/l10n/generated/sample_app_localizations_es.dart index 9294c91e0..a268194a9 100644 --- a/packages/sample-app/lib/l10n/generated/sample_app_localizations_es.dart +++ b/packages/sample-app/lib/l10n/generated/sample_app_localizations_es.dart @@ -1,6 +1,5 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; - import 'sample_app_localizations.dart'; // ignore_for_file: type=lint diff --git a/packages/sample-app/lib/l10n/generated/sample_app_localizations_fr.dart b/packages/sample-app/lib/l10n/generated/sample_app_localizations_fr.dart index c44033010..717733269 100644 --- a/packages/sample-app/lib/l10n/generated/sample_app_localizations_fr.dart +++ b/packages/sample-app/lib/l10n/generated/sample_app_localizations_fr.dart @@ -1,6 +1,5 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; - import 'sample_app_localizations.dart'; // ignore_for_file: type=lint diff --git a/packages/sample-app/lib/l10n/generated/sample_app_localizations_it.dart b/packages/sample-app/lib/l10n/generated/sample_app_localizations_it.dart index 47056277a..fdc3c3a39 100644 --- a/packages/sample-app/lib/l10n/generated/sample_app_localizations_it.dart +++ b/packages/sample-app/lib/l10n/generated/sample_app_localizations_it.dart @@ -1,6 +1,5 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; - import 'sample_app_localizations.dart'; // ignore_for_file: type=lint diff --git a/packages/sample-app/lib/l10n/generated/sample_app_localizations_ja.dart b/packages/sample-app/lib/l10n/generated/sample_app_localizations_ja.dart index 68fc6d579..9e1780c77 100644 --- a/packages/sample-app/lib/l10n/generated/sample_app_localizations_ja.dart +++ b/packages/sample-app/lib/l10n/generated/sample_app_localizations_ja.dart @@ -1,6 +1,5 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; - import 'sample_app_localizations.dart'; // ignore_for_file: type=lint diff --git a/packages/sample-app/lib/l10n/generated/sample_app_localizations_pl.dart b/packages/sample-app/lib/l10n/generated/sample_app_localizations_pl.dart index db13dfb9b..833ce34b7 100644 --- a/packages/sample-app/lib/l10n/generated/sample_app_localizations_pl.dart +++ b/packages/sample-app/lib/l10n/generated/sample_app_localizations_pl.dart @@ -1,6 +1,5 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; - import 'sample_app_localizations.dart'; // ignore_for_file: type=lint diff --git a/packages/sample-app/lib/l10n/generated/sample_app_localizations_ru.dart b/packages/sample-app/lib/l10n/generated/sample_app_localizations_ru.dart index 99c6dbf86..cf7a3ed9d 100644 --- a/packages/sample-app/lib/l10n/generated/sample_app_localizations_ru.dart +++ b/packages/sample-app/lib/l10n/generated/sample_app_localizations_ru.dart @@ -1,6 +1,5 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; - import 'sample_app_localizations.dart'; // ignore_for_file: type=lint diff --git a/packages/sample-app/lib/l10n/generated/sample_app_localizations_zh.dart b/packages/sample-app/lib/l10n/generated/sample_app_localizations_zh.dart index a66fea40b..9b7f2e424 100644 --- a/packages/sample-app/lib/l10n/generated/sample_app_localizations_zh.dart +++ b/packages/sample-app/lib/l10n/generated/sample_app_localizations_zh.dart @@ -1,6 +1,5 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; - import 'sample_app_localizations.dart'; // ignore_for_file: type=lint diff --git a/packages/sample-app/lib/wallets.dart b/packages/sample-app/lib/wallets.dart index 36e5717d9..d14057676 100644 --- a/packages/sample-app/lib/wallets.dart +++ b/packages/sample-app/lib/wallets.dart @@ -3,6 +3,7 @@ import 'package:ndk_demo/l10n/app_localizations_context.dart'; import 'package:ndk_flutter/ndk_flutter.dart'; import 'main.dart'; +import 'bolt12_qr_scanner.dart'; import 'nwc_qr_scanner.dart'; class WalletsPage extends StatefulWidget { @@ -73,6 +74,7 @@ class WalletsPageState extends State with WidgetsBindingObserver { key: _walletsKey, ndkFlutter: ndkFlutter, nwcUriScanner: scanNwcUri, + bolt12InputScanner: scanBolt12Input, ), ); } diff --git a/packages/sample-app/pubspec.lock b/packages/sample-app/pubspec.lock index cfd5aec14..ec59afcce 100644 --- a/packages/sample-app/pubspec.lock +++ b/packages/sample-app/pubspec.lock @@ -161,6 +161,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + dart_bip353: + dependency: transitive + description: + name: dart_bip353 + sha256: "269daf722556e66c56cf62f7c7d584bd8f43e7480113f31001f382135f8899c5" + url: "https://pub.dev" + source: hosted + version: "0.8.0" + dart_bolt12_decoder: + dependency: transitive + description: + name: dart_bolt12_decoder + sha256: c61b34b6922c5f64d6f50d16c479bd3f0189a093557d59f4eb69424c51dd3d57 + url: "https://pub.dev" + source: hosted + version: "0.8.0" dbus: dependency: transitive description: @@ -401,10 +417,10 @@ packages: dependency: "direct main" description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.dev" source: hosted - version: "0.20.2" + version: "0.20.3" jni: dependency: transitive description: @@ -465,10 +481,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -545,10 +561,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" mobile_scanner: dependency: "direct main" description: @@ -579,7 +595,7 @@ packages: path: "../ndk" relative: true source: path - version: "0.8.4-dev.10" + version: "0.8.4-dev.11" ndk_bip32_keys: dependency: transitive description: @@ -594,14 +610,14 @@ packages: path: "../drift" relative: true source: path - version: "0.1.1-dev.12" + version: "0.1.1-dev.13" ndk_flutter: dependency: "direct main" description: path: "../ndk_flutter" relative: true source: path - version: "0.8.4-dev.13" + version: "0.8.4-dev.14" nested: dependency: transitive description: @@ -979,10 +995,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" toastification: dependency: transitive description: @@ -1107,10 +1123,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" vm_service: dependency: transitive description: From 5b90677bc70d17cecf879d4467ed49448283c74c Mon Sep 17 00:00:00 2001 From: fmar Date: Fri, 28 Aug 2026 13:28:47 +0200 Subject: [PATCH 05/22] no wallet compatible --- packages/ndk_flutter/lib/l10n/app_en.arb | 1 + .../lib/l10n/app_localizations.dart | 6 +++ .../lib/l10n/app_localizations_de.dart | 4 ++ .../lib/l10n/app_localizations_en.dart | 4 ++ .../lib/l10n/app_localizations_es.dart | 4 ++ .../lib/l10n/app_localizations_fi.dart | 4 ++ .../lib/l10n/app_localizations_fr.dart | 4 ++ .../lib/l10n/app_localizations_it.dart | 4 ++ .../lib/l10n/app_localizations_ja.dart | 4 ++ .../lib/l10n/app_localizations_pl.dart | 4 ++ .../lib/l10n/app_localizations_pt.dart | 4 ++ .../lib/l10n/app_localizations_ru.dart | 4 ++ .../lib/l10n/app_localizations_sk.dart | 4 ++ .../lib/l10n/app_localizations_zh.dart | 4 ++ .../wallets/wallet_action_dialogs.dart | 41 +++++++++++++------ 15 files changed, 84 insertions(+), 12 deletions(-) diff --git a/packages/ndk_flutter/lib/l10n/app_en.arb b/packages/ndk_flutter/lib/l10n/app_en.arb index a26ae340c..c28dfbc3f 100644 --- a/packages/ndk_flutter/lib/l10n/app_en.arb +++ b/packages/ndk_flutter/lib/l10n/app_en.arb @@ -1018,6 +1018,7 @@ "sendToWallet": "Send to Wallet", "sendToWalletDescription": "Transfer to another compatible wallet", "noCompatibleReceivingWallets": "No compatible receiving wallets", + "noCompatibleReceivingWalletsDescription": "Add or connect another wallet that can receive a payment supported by this wallet.", "destinationWallet": "Destination wallet", "walletTransferSubmitted": "Payment sent to {walletName}", "@walletTransferSubmitted": { diff --git a/packages/ndk_flutter/lib/l10n/app_localizations.dart b/packages/ndk_flutter/lib/l10n/app_localizations.dart index 2dd7197bd..1e5821e32 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations.dart @@ -1637,6 +1637,12 @@ abstract class AppLocalizations { /// **'No compatible receiving wallets'** String get noCompatibleReceivingWallets; + /// No description provided for @noCompatibleReceivingWalletsDescription. + /// + /// In en, this message translates to: + /// **'Add or connect another wallet that can receive a payment supported by this wallet.'** + String get noCompatibleReceivingWalletsDescription; + /// No description provided for @destinationWallet. /// /// In en, this message translates to: diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_de.dart b/packages/ndk_flutter/lib/l10n/app_localizations_de.dart index d8b89327c..48a7536d0 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_de.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_de.dart @@ -780,6 +780,10 @@ class AppLocalizationsDe extends AppLocalizations { @override String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + @override + String get noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + @override String get destinationWallet => 'Destination wallet'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_en.dart b/packages/ndk_flutter/lib/l10n/app_localizations_en.dart index b899b9fbd..3783ee751 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_en.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_en.dart @@ -779,6 +779,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + @override + String get noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + @override String get destinationWallet => 'Destination wallet'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_es.dart b/packages/ndk_flutter/lib/l10n/app_localizations_es.dart index a6006e698..3af077a19 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_es.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_es.dart @@ -782,6 +782,10 @@ class AppLocalizationsEs extends AppLocalizations { @override String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + @override + String get noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + @override String get destinationWallet => 'Destination wallet'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart b/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart index 6a440e210..02136ee93 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart @@ -780,6 +780,10 @@ class AppLocalizationsFi extends AppLocalizations { @override String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + @override + String get noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + @override String get destinationWallet => 'Destination wallet'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart b/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart index 77bcea2b5..eed077410 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart @@ -781,6 +781,10 @@ class AppLocalizationsFr extends AppLocalizations { @override String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + @override + String get noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + @override String get destinationWallet => 'Destination wallet'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_it.dart b/packages/ndk_flutter/lib/l10n/app_localizations_it.dart index fab6c3ace..cadef2105 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_it.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_it.dart @@ -783,6 +783,10 @@ class AppLocalizationsIt extends AppLocalizations { @override String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + @override + String get noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + @override String get destinationWallet => 'Destination wallet'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart b/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart index 90dcbb34f..737f7765d 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart @@ -773,6 +773,10 @@ class AppLocalizationsJa extends AppLocalizations { @override String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + @override + String get noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + @override String get destinationWallet => 'Destination wallet'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart b/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart index 8633cbca1..81d31fb62 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart @@ -783,6 +783,10 @@ class AppLocalizationsPl extends AppLocalizations { @override String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + @override + String get noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + @override String get destinationWallet => 'Destination wallet'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart b/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart index 3572c560b..65dd1e198 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart @@ -784,6 +784,10 @@ class AppLocalizationsPt extends AppLocalizations { @override String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + @override + String get noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + @override String get destinationWallet => 'Destination wallet'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart b/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart index 7b0e4f065..7d523643f 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart @@ -779,6 +779,10 @@ class AppLocalizationsRu extends AppLocalizations { @override String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + @override + String get noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + @override String get destinationWallet => 'Destination wallet'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart b/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart index 09d35ef49..6f6ea6c4e 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart @@ -780,6 +780,10 @@ class AppLocalizationsSk extends AppLocalizations { @override String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + @override + String get noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + @override String get destinationWallet => 'Destination wallet'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart b/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart index 94519e3c5..0dc6fb56b 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart @@ -773,6 +773,10 @@ class AppLocalizationsZh extends AppLocalizations { @override String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + @override + String get noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + @override String get destinationWallet => 'Destination wallet'; diff --git a/packages/ndk_flutter/lib/widgets/wallets/wallet_action_dialogs.dart b/packages/ndk_flutter/lib/widgets/wallets/wallet_action_dialogs.dart index 6c3feb23f..898fb2bdc 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/wallet_action_dialogs.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/wallet_action_dialogs.dart @@ -455,11 +455,12 @@ mixin WalletActionDialogsMixin on State { final destinations = wallets .where( (destination) => + destination.id != wallet.id && ndkFlutter.ndk.wallets.compatibleTransferProtocol( - source: wallet, - destination: destination, - ) != - null, + source: wallet, + destination: destination, + ) != + null, ) .toList(); @@ -507,7 +508,6 @@ mixin WalletActionDialogsMixin on State { ), ], ListTile( - enabled: destinations.isNotEmpty, leading: const Icon(Icons.swap_horiz), title: Text(l10n.sendToWallet), subtitle: Text( @@ -515,12 +515,8 @@ mixin WalletActionDialogsMixin on State { ? l10n.noCompatibleReceivingWallets : l10n.sendToWalletDescription, ), - onTap: destinations.isEmpty - ? null - : () => Navigator.pop( - sheetContext, - _WalletSendAction.transfer, - ), + onTap: () => + Navigator.pop(sheetContext, _WalletSendAction.transfer), ), const SizedBox(height: 16), ], @@ -536,10 +532,31 @@ mixin WalletActionDialogsMixin on State { case _WalletSendAction.invoice: _showPayInvoiceDialog(context, wallet); case _WalletSendAction.transfer: - await _showWalletTransferDialog(context, wallet, destinations); + if (destinations.isEmpty) { + await _showNoCompatibleWalletsDialog(context); + } else { + await _showWalletTransferDialog(context, wallet, destinations); + } } } + Future _showNoCompatibleWalletsDialog(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.noCompatibleReceivingWallets), + content: Text(l10n.noCompatibleReceivingWalletsDescription), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(l10n.close), + ), + ], + ), + ); + } + Future _showWalletTransferDialog( BuildContext context, Wallet source, From 3416e6332a1690cc302a823ac3efdad144e61de6 Mon Sep 17 00:00:00 2001 From: fmar Date: Sat, 22 Aug 2026 16:49:53 +0200 Subject: [PATCH 06/22] add timeout to nwc makeHoldInvoice --- packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart b/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart index db7ac06ab..085866ea9 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart @@ -473,6 +473,7 @@ class Nwc { String? descriptionHash, int? expiry, required String paymentHash, + Duration? timeout, }) async { return _executeRequest( connection, @@ -483,6 +484,7 @@ class Nwc { expiry: expiry, paymentHash: paymentHash, ), + timeout: timeout, ); } From 484f9317871019af7a01f823132d6b92055ea55f Mon Sep 17 00:00:00 2001 From: fmar Date: Fri, 28 Aug 2026 17:52:23 +0200 Subject: [PATCH 07/22] fix: not add lnurl wallet if invalid --- .../usecases/wallets/wallets.dart | 31 +++++++---------- .../ndk/test/usecases/lnurl/lnurl_test.dart | 34 +++++++++++++++++++ 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart b/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart index 9ad956c36..39c66fac3 100644 --- a/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart +++ b/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart @@ -260,35 +260,28 @@ class Wallets { /// Add a new wallet to the system Future addWallet(Wallet wallet) async { - await _repository.storeWallet(wallet); - await _addWalletToMemory(wallet); - - // Initialize with provider + // Initialize before persisting so failed setup (for example, an + // unreachable LNURL endpoint) cannot leave a partially added wallet. final provider = _providers[wallet.type]; + var walletToStore = wallet; if (provider != null) { final updatedWallet = await provider.initialize(wallet); if (updatedWallet != null) { - // Replace old wallet with updated one while preserving order - final list = _wallets.toList(); - final existingIndex = list.indexWhere((w) => w.id == wallet.id); - if (existingIndex >= 0) { - list[existingIndex] = updatedWallet; - _wallets.clear(); - _wallets.addAll(list); - _safeAddWallets(list); - } - // Also update in repository (addWallet handles updates too) - await _repository.storeWallet(updatedWallet); + walletToStore = updatedWallet; } } - if (wallet.canReceive && + await _repository.storeWallet(walletToStore); + await _addWalletToMemory(walletToStore); + + if (walletToStore.canReceive && _repository.getDefaultWalletIdForReceiving() == null) { - _repository.setDefaultWalletForReceiving(wallet.id); + _repository.setDefaultWalletForReceiving(walletToStore.id); } - if (wallet.canSend && _repository.getDefaultWalletIdForSending() == null) { - _repository.setDefaultWalletForSending(wallet.id); + if (walletToStore.canSend && + _repository.getDefaultWalletIdForSending() == null) { + _repository.setDefaultWalletForSending(walletToStore.id); } _updateCombinedStreams(); diff --git a/packages/ndk/test/usecases/lnurl/lnurl_test.dart b/packages/ndk/test/usecases/lnurl/lnurl_test.dart index 4ef4ad564..d301d6b09 100644 --- a/packages/ndk/test/usecases/lnurl/lnurl_test.dart +++ b/packages/ndk/test/usecases/lnurl/lnurl_test.dart @@ -5,7 +5,11 @@ import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:ndk/data_layer/data_sources/http_request.dart'; import 'package:ndk/data_layer/repositories/lnurl_http_impl.dart'; +import 'package:ndk/data_layer/repositories/wallets/mem_wallets_repo.dart'; +import 'package:ndk/domain_layer/entities/wallet/providers/lnurl/lnurl_wallet_provider.dart'; +import 'package:ndk/domain_layer/entities/wallet/wallet_type.dart'; import 'package:ndk/domain_layer/usecases/lnurl/lnurl.dart'; +import 'package:ndk/domain_layer/usecases/wallets/wallets.dart'; import 'package:test/test.dart'; import 'lnurl_test.mocks.dart'; @@ -59,6 +63,36 @@ void main() { expect(lnurlResponse, isNull); }); + test('failed metadata fetch does not add an LNURL wallet', () async { + final client = MockClient(); + final transport = LnurlTransportHttpImpl(HttpRequestDS(client)); + final lnurl = Lnurl(transport: transport); + final repository = MemWalletsRepo(); + final wallets = Wallets( + providers: [LnurlWalletProvider(lnurl)], + repository: repository, + ); + addTearDown(wallets.dispose); + await wallets.getWallets(); + + const identifier = 'name@domain.com'; + final link = Lnurl.getLud16LinkFromLud16(identifier)!; + when(client.get(Uri.parse(link), headers: {'Accept': 'application/json'})) + .thenAnswer((_) async => http.Response('not found', 404)); + + final wallet = wallets.createWallet( + id: 'lnurl-wallet', + name: identifier, + type: WalletType.LNURL, + supportedUnits: {'sat'}, + metadata: {'identifier': identifier}, + ); + + await expectLater(wallets.addWallet(wallet), throwsException); + expect(await repository.getWallets(), isEmpty); + expect(await wallets.getWallets(), isEmpty); + }); + test('getAmountFromBolt11 returns correct amount for valid input', () { final amount = Lnurl.getAmountFromBolt11( 'lnbc15u1p3xnhl2pp5jptserfk3zk4qy42tlucycrfwxhydvlemu9pqr93tuzlv9cc7g3sdqsvfhkcap3xyhx7un8cqzpgxqzjcsp5f8c52y2stc300gl6s4xswtjpc37hrnnr3c9wvtgjfuvqmpm35evq9qyyssqy4lgd8tj637qcjp05rdpxxykjenthxftej7a2zzmwrmrl70fyj9hvj0rewhzj7jfyuwkwcg9g2jpwtk3wkjtwnkdks84hsnu8xps5vsq4gj5hs', From 06b4accc14488427d0d9d3cdc604a4ea6b41422b Mon Sep 17 00:00:00 2001 From: fmar Date: Thu, 3 Sep 2026 23:29:23 +0200 Subject: [PATCH 08/22] perf: improve rust verifier memory usage --- .../verifiers/rust_event_verifier_native.dart | 97 ++++++++----------- packages/ndk/lib/src/rust_lib.dart | 11 +++ packages/ndk/rust/src/lib.rs | 76 ++++++++++----- .../verifiers/rust_event_verifier_test.dart | 41 ++++++++ 4 files changed, 141 insertions(+), 84 deletions(-) diff --git a/packages/ndk/lib/data_layer/repositories/verifiers/rust_event_verifier_native.dart b/packages/ndk/lib/data_layer/repositories/verifiers/rust_event_verifier_native.dart index 6fba6c03e..9cf74b917 100644 --- a/packages/ndk/lib/data_layer/repositories/verifiers/rust_event_verifier_native.dart +++ b/packages/ndk/lib/data_layer/repositories/verifiers/rust_event_verifier_native.dart @@ -3,6 +3,7 @@ import 'dart:ffi'; import 'package:ffi/ffi.dart'; import '../../../domain_layer/entities/nip_01_event.dart'; +import '../../../domain_layer/entities/nip_01_utils.dart'; import '../../../domain_layer/repositories/event_verifier.dart'; import '../../../src/rust_lib.dart' as rust_lib; @@ -16,71 +17,51 @@ class RustEventVerifier implements EventVerifier { @override Future verify(Nip01Event event) async { - // Check if signature is present - if (event.sig == null) { + final signature = event.sig; + if (signature == null || + !_isHex(event.id, 64) || + !_isHex(event.pubKey, 64) || + !_isHex(signature, 128) || + !Nip01Utils.isIdValid(event)) { return false; } - // Convert strings to native pointers - final eventIdPtr = event.id.toNativeUtf8(); - final pubKeyPtr = event.pubKey.toNativeUtf8(); - final contentPtr = event.content.toNativeUtf8(); - final signaturePtr = event.sig!.toNativeUtf8(); - - // Prepare tags data - final tags = event.tags; - final tagsCount = tags.length; - - // Calculate total number of strings across all tags - int totalStrings = 0; - for (final tag in tags) { - totalStrings += tag.length; - } - - // Allocate arrays for tags - final tagsLengths = calloc(tagsCount == 0 ? 1 : tagsCount); - final tagsData = calloc>( - totalStrings == 0 ? 1 : totalStrings, - ); + // The validated event id commits to every event field. Only the fixed-size + // Schnorr inputs need to cross FFI. One packed allocation replaces the + // previous allocation per field, tag, and nested Rust String. + const packedLength = 64 + 64 + 128; + final packed = malloc(packedLength); try { - // Fill tag data - int stringIndex = 0; - for (int i = 0; i < tagsCount; i++) { - tagsLengths[i] = tags[i].length; - for (final element in tags[i]) { - tagsData[stringIndex] = element.toNativeUtf8(); - stringIndex++; - } - } - - // Call the native function - final result = rust_lib.verifyNostrEventNative( - eventIdPtr, - pubKeyPtr, - event.createdAt, - event.kind, - tagsData, - tagsLengths, - tagsCount, - contentPtr, - signaturePtr, - ); - - return result == 1; + final bytes = packed.asTypedList(packedLength); + _copyAscii(event.id, bytes, 0); + _copyAscii(event.pubKey, bytes, 64); + _copyAscii(signature, bytes, 128); + return rust_lib.verifySchnorrSignaturePackedNative( + packed, + packedLength, + ) == + 1; } finally { - // Free all allocated memory - calloc.free(eventIdPtr); - calloc.free(pubKeyPtr); - calloc.free(contentPtr); - calloc.free(signaturePtr); + malloc.free(packed); + } + } + + static bool _isHex(String value, int expectedLength) { + if (value.length != expectedLength) return false; + for (final codeUnit in value.codeUnits) { + final digit = codeUnit >= 0x30 && codeUnit <= 0x39; + final lower = codeUnit >= 0x61 && codeUnit <= 0x66; + final upper = codeUnit >= 0x41 && codeUnit <= 0x46; + if (!digit && !lower && !upper) return false; + } + return true; + } - // Free tag string pointers - for (int i = 0; i < totalStrings; i++) { - calloc.free(tagsData[i]); - } - calloc.free(tagsData); - calloc.free(tagsLengths); + static void _copyAscii(String source, List target, int offset) { + final codeUnits = source.codeUnits; + for (var index = 0; index < codeUnits.length; index++) { + target[offset + index] = codeUnits[index]; } } } diff --git a/packages/ndk/lib/src/rust_lib.dart b/packages/ndk/lib/src/rust_lib.dart index fcf69eb2b..1ba83ffd1 100644 --- a/packages/ndk/lib/src/rust_lib.dart +++ b/packages/ndk/lib/src/rust_lib.dart @@ -39,6 +39,17 @@ external int verifyNostrEventNative( Pointer signatureHex, ); +/// Verifies a Nostr Schnorr signature from one packed ASCII buffer containing +/// event id (64 bytes), pubkey (64 bytes), and signature (128 bytes). +@Native, IntPtr)>( + symbol: 'verify_schnorr_signature_packed', + isLeaf: true, +) +external int verifySchnorrSignaturePackedNative( + Pointer packed, + int packedLength, +); + // ── Quantum-Secure ML-DSA (FIPS 204) bindings ────────────────────────── // // These were CRYSTALS-Dilithium. NIST altered the algorithm during diff --git a/packages/ndk/rust/src/lib.rs b/packages/ndk/rust/src/lib.rs index 6b6ce23ab..d4b8b551e 100644 --- a/packages/ndk/rust/src/lib.rs +++ b/packages/ndk/rust/src/lib.rs @@ -5,7 +5,7 @@ use std::slice; use fips204::traits::{KeyGen, SerDes, Signer, Verifier}; use fips204::{ml_dsa_44, ml_dsa_65, ml_dsa_87}; -use hex::decode; +use hex::decode_to_slice; use hkdf::Hkdf; use secp256k1::{schnorr::Signature, XOnlyPublicKey, SECP256K1}; use sha2::{Digest, Sha256}; @@ -118,39 +118,63 @@ pub unsafe extern "C" fn verify_schnorr_signature( } } +/// Verifies a Schnorr signature from one fixed-size packed ASCII buffer: +/// event id (64 bytes), pubkey (64 bytes), signature (128 bytes). +/// +/// # Safety +/// `packed` must point to `packed_len` readable bytes. The function rejects +/// null pointers and every length other than 256 before reading the buffer. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn verify_schnorr_signature_packed( + packed: *const u8, + packed_len: usize, +) -> i32 { + const PACKED_LEN: usize = 64 + 64 + 128; + if packed.is_null() || packed_len != PACKED_LEN { + return 0; + } + let bytes = unsafe { slice::from_raw_parts(packed, packed_len) }; + let event_id_hex = &bytes[..64]; + let pub_key_hex = &bytes[64..128]; + let signature_hex = &bytes[128..]; + + if verify_schnorr_signature_bytes(pub_key_hex, event_id_hex, signature_hex) { + 1 + } else { + 0 + } +} + fn verify_schnorr_signature_internal( pub_key_hex: &str, event_id_hex: &str, signature_hex: &str, ) -> bool { - let pub_key_bytes = match decode(pub_key_hex) { - Ok(bytes) => bytes, - Err(_) => return false, - }; - - let event_id_bytes = match decode(event_id_hex) { - Ok(bytes) => bytes, - Err(_) => return false, - }; - - let signature_bytes = match decode(signature_hex) { - Ok(bytes) => bytes, - Err(_) => return false, - }; + verify_schnorr_signature_bytes( + pub_key_hex.as_bytes(), + event_id_hex.as_bytes(), + signature_hex.as_bytes(), + ) +} - if event_id_bytes.len() != 32 || pub_key_bytes.len() != 32 || signature_bytes.len() != 64 { +fn verify_schnorr_signature_bytes( + pub_key_hex: &[u8], + event_id_hex: &[u8], + signature_hex: &[u8], +) -> bool { + if pub_key_hex.len() != 64 || event_id_hex.len() != 64 || signature_hex.len() != 128 { return false; } - let pub_key_array: [u8; 32] = match pub_key_bytes.try_into() { - Ok(arr) => arr, - Err(_) => return false, - }; - - let signature_array: [u8; 64] = match signature_bytes.try_into() { - Ok(arr) => arr, - Err(_) => return false, - }; + let mut pub_key_array = [0u8; 32]; + let mut event_id_array = [0u8; 32]; + let mut signature_array = [0u8; 64]; + if decode_to_slice(pub_key_hex, &mut pub_key_array).is_err() + || decode_to_slice(event_id_hex, &mut event_id_array).is_err() + || decode_to_slice(signature_hex, &mut signature_array).is_err() + { + return false; + } let pubkey = match XOnlyPublicKey::from_byte_array(pub_key_array) { Ok(key) => key, @@ -160,7 +184,7 @@ fn verify_schnorr_signature_internal( let signature = Signature::from_byte_array(signature_array); SECP256K1 - .verify_schnorr(&signature, &event_id_bytes, &pubkey) + .verify_schnorr(&signature, &event_id_array, &pubkey) .is_ok() } diff --git a/packages/ndk/test/verifiers/rust_event_verifier_test.dart b/packages/ndk/test/verifiers/rust_event_verifier_test.dart index 15001ecc0..8a598ccbe 100644 --- a/packages/ndk/test/verifiers/rust_event_verifier_test.dart +++ b/packages/ndk/test/verifiers/rust_event_verifier_test.dart @@ -179,5 +179,46 @@ void main() { final result = await verifier.verify(event); expect(result, isTrue); }); + + test('rejects malformed fixed-size signature fields', () async { + final event = Nip01Event( + id: 'z' * 64, + pubKey: keyPair.publicKey, + kind: 1, + tags: const [], + content: '', + sig: '0' * 128, + ); + + expect(await verifier.verify(event), isFalse); + }); + + test('repeatedly verifies an event with many large tags', () async { + final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final tags = List.generate( + 200, + (index) => ['x', '$index-${'a' * 1024}'], + ); + final id = Nip01Utils.calculateEventIdSync( + pubKey: keyPair.publicKey, + createdAt: createdAt, + kind: 1, + tags: tags, + content: 'large tagged event', + ); + final event = Nip01Event( + id: id, + pubKey: keyPair.publicKey, + createdAt: createdAt, + kind: 1, + tags: tags, + content: 'large tagged event', + sig: Bip340.sign(id, keyPair.privateKey!), + ); + + for (var iteration = 0; iteration < 100; iteration++) { + expect(await verifier.verify(event), isTrue); + } + }); }); } From 6478115488756f68c54ea9ac013d8a5f14156c7f Mon Sep 17 00:00:00 2001 From: fmar Date: Mon, 7 Sep 2026 14:40:13 +0200 Subject: [PATCH 09/22] remove deps --- .../providers/bolt12/bolt12_wallet.dart | 4 - .../bolt12/bolt12_wallet_provider.dart | 109 +++++++++++++----- packages/ndk/pubspec.yaml | 2 - .../ndk/test/entities/bolt12_wallet_test.dart | 70 +++++++++++ packages/sample-app/pubspec.lock | 16 --- 5 files changed, 150 insertions(+), 51 deletions(-) diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet.dart index 2d19ff30b..9cc23ac11 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet.dart @@ -14,7 +14,6 @@ class Bolt12Wallet extends Wallet { final String? description; final String? nodeId; - final String? offerId; final String? amount; final String? issuer; final String? currency; @@ -32,7 +31,6 @@ class Bolt12Wallet extends Wallet { this.bip353Address, this.description, this.nodeId, - this.offerId, this.amount, this.issuer, this.currency, @@ -48,7 +46,6 @@ class Bolt12Wallet extends Wallet { 'bip353Address': bip353Address, 'description': description, 'nodeId': nodeId, - 'offerId': offerId, 'amount': amount, 'issuer': issuer, 'currency': currency, @@ -81,7 +78,6 @@ class Bolt12Wallet extends Wallet { bip353Address: metadata['bip353Address'] as String?, description: metadata['description'] as String?, nodeId: metadata['nodeId'] as String?, - offerId: metadata['offerId'] as String?, amount: metadata['amount']?.toString(), issuer: metadata['issuer'] as String?, currency: metadata['currency'] as String?, diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet_provider.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet_provider.dart index 737d167e5..3f3986ca4 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet_provider.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet_provider.dart @@ -1,8 +1,7 @@ import 'dart:async'; import 'dart:convert'; -import 'package:dart_bip353/dart_bip353.dart'; -import 'package:dart_bolt12_decoder/dart_bolt12_decoder.dart'; +import 'package:http/http.dart' as http; import 'package:ndk/domain_layer/usecases/nwc/responses/pay_invoice_response.dart'; import 'package:ndk/domain_layer/usecases/nwc/responses/pay_response.dart'; @@ -35,7 +34,6 @@ class Bolt12ResolvedOffer { 'bip353Address': bip353Address, 'description': _nonEmptyString(decoded['offer_description']), 'nodeId': _nonEmptyString(decoded['offer_node_id']), - 'offerId': _nonEmptyString(decoded['offer_id']), 'amount': _nonEmptyString(decoded['offer_amount']), 'issuer': _nonEmptyString(decoded['offer_issuer']), 'currency': _nonEmptyString(decoded['offer_currency']), @@ -64,6 +62,10 @@ class Bolt12ResolvedOffer { /// Provider for receive-only BOLT12 offer wallets. class Bolt12WalletProvider implements WalletProvider { + static final Uri defaultBip353DohEndpoint = Uri.parse( + 'https://cloudflare-dns.com/dns-query', + ); + const Bolt12WalletProvider(); @override @@ -84,6 +86,8 @@ class Bolt12WalletProvider implements WalletProvider { static Future resolveInput( String input, { Bip353OfferResolver? bip353Resolver, + Uri? bip353DohEndpoint, + http.Client? httpClient, }) async { final source = input.trim(); if (source.isEmpty) { @@ -102,8 +106,13 @@ class Bolt12WalletProvider implements WalletProvider { ); } - final resolver = bip353Resolver ?? _resolveBip353; - final resolvedOffer = await resolver(address); + final resolvedOffer = bip353Resolver != null + ? await bip353Resolver(address) + : await _resolveBip353( + address, + endpoint: bip353DohEndpoint ?? defaultBip353DohEndpoint, + client: httpClient, + ); if (resolvedOffer == null || resolvedOffer.trim().isEmpty) { throw FormatException( 'BIP353 address $address does not publish a BOLT12 offer', @@ -117,9 +126,71 @@ class Bolt12WalletProvider implements WalletProvider { ); } - static Future _resolveBip353(String address) async { - final response = await Bip353.getAdressResolve(address); - return response.offer; + static Future _resolveBip353( + String address, { + required Uri endpoint, + http.Client? client, + }) async { + final parts = address.split('@'); + final query = '${parts[0]}.user._bitcoin-payment.${parts[1]}'; + final uri = endpoint.replace( + queryParameters: { + ...endpoint.queryParameters, + 'name': query, + 'type': 'TXT', + }, + ); + final response = client == null + ? await http.get(uri, headers: const {'Accept': 'application/dns-json'}) + : await client.get( + uri, + headers: const {'Accept': 'application/dns-json'}, + ); + if (response.statusCode != 200) { + throw FormatException( + 'BIP353 DNS query failed with HTTP ${response.statusCode}', + ); + } + + final Object? body; + try { + body = jsonDecode(response.body); + } on FormatException { + throw const FormatException('Invalid BIP353 DNS response'); + } + if (body is! Map || body['Status'] != 0) { + throw const FormatException('BIP353 DNS query failed'); + } + if (body['AD'] != true) { + throw const FormatException( + 'BIP353 DNS response is not authenticated by DNSSEC', + ); + } + + final answers = body['Answer']; + if (answers is! List) return null; + for (final answer in answers) { + if (answer is! Map || answer['type'] != 16 || answer['data'] is! String) { + continue; + } + final paymentInstruction = _decodeTxtRecord(answer['data'] as String); + final offer = _directOffer(paymentInstruction); + if (offer != null) return offer; + } + return null; + } + + static String _decodeTxtRecord(String data) { + final chunks = RegExp(r'"((?:\\.|[^"\\])*)"').allMatches(data).toList(); + if (chunks.isEmpty) return data.trim(); + return chunks.map((match) { + final chunk = match.group(1)!; + try { + return jsonDecode('"$chunk"') as String; + } on FormatException { + throw const FormatException('Invalid BIP353 TXT record'); + } + }).join(); } static Bolt12ResolvedOffer _validate({ @@ -130,25 +201,11 @@ class Bolt12WalletProvider implements WalletProvider { final envelope = _Bolt12OfferEnvelope.parse(offer); final canonicalOffer = envelope.canonicalOffer; - // dart_bolt12_decoder 0.8.0 predates the current blinded-path encoding - // and also requires description + issuer id, which modern offers may omit. - // Use it for the offer shapes it understands and retain strict structural - // validation for current-spec offers. - Map decoded = envelope.details; - if (envelope.isSupportedByDetailDecoder) { - final packageDecoded = Bolt12Decoder.decode(canonicalOffer); - if (packageDecoded != null && - packageDecoded['type'] == 'offer' && - packageDecoded['valid'] == true) { - decoded = {...decoded, ...packageDecoded}; - } - } - return Bolt12ResolvedOffer( offer: canonicalOffer, source: source, bip353Address: bip353Address, - decoded: Map.unmodifiable(decoded), + decoded: envelope.details, ); } @@ -221,7 +278,6 @@ class Bolt12WalletProvider implements WalletProvider { bip353Address: validated.bip353Address, description: resolvedMetadata['description'] as String?, nodeId: resolvedMetadata['nodeId'] as String?, - offerId: resolvedMetadata['offerId'] as String?, amount: resolvedMetadata['amount']?.toString(), issuer: resolvedMetadata['issuer'] as String?, currency: resolvedMetadata['currency'] as String?, @@ -327,11 +383,6 @@ class _Bolt12OfferEnvelope { required this.details, }); - bool get isSupportedByDetailDecoder => - !fields.containsKey(16) && - fields.containsKey(10) && - fields.containsKey(22); - static _Bolt12OfferEnvelope parse(String input) { final withoutContinuations = input.trim().replaceAll( RegExp(r'\+\s*'), diff --git a/packages/ndk/pubspec.yaml b/packages/ndk/pubspec.yaml index 4278ebb1a..fa246d1c5 100644 --- a/packages/ndk/pubspec.yaml +++ b/packages/ndk/pubspec.yaml @@ -24,8 +24,6 @@ platforms: windows: dependencies: - dart_bip353: ^0.8.0 - dart_bolt12_decoder: ^0.8.0 bip32_keys: ^3.1.4 http: ^1.6.0 bip340: ">=0.3.0 <0.4.0" diff --git a/packages/ndk/test/entities/bolt12_wallet_test.dart b/packages/ndk/test/entities/bolt12_wallet_test.dart index 1ecf9c07a..1fbe3fb63 100644 --- a/packages/ndk/test/entities/bolt12_wallet_test.dart +++ b/packages/ndk/test/entities/bolt12_wallet_test.dart @@ -1,3 +1,7 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:ndk/entities.dart'; import 'package:test/test.dart'; @@ -15,6 +19,7 @@ void main() { expect(resolved.decoded['type'], 'offer'); expect(resolved.decoded['valid'], isTrue); expect(resolved.decoded['offer_description'], isNotEmpty); + expect(resolved.toMetadata(), isNot(contains('offerId'))); }); test('extracts an offer from a BIP321 URI', () async { @@ -72,6 +77,71 @@ void main() { ); }); + test('resolves DNSSEC-authenticated BIP353 through custom DoH', () async { + final endpoint = Uri.parse('https://resolver.example/dns-query'); + final client = MockClient((request) async { + expect(request.url.origin + request.url.path, endpoint.toString()); + expect( + request.url.queryParameters['name'], + 'alice.user._bitcoin-payment.example.com', + ); + expect(request.url.queryParameters['type'], 'TXT'); + expect(request.headers['Accept'], 'application/dns-json'); + return http.Response( + jsonEncode({ + 'Status': 0, + 'AD': true, + 'Answer': [ + { + 'type': 16, + 'data': + '"bitcoin:?lno=${_offer.substring(0, 60)}" ' + '"${_offer.substring(60)}"', + }, + ], + }), + 200, + ); + }); + + final resolved = await Bolt12WalletProvider.resolveInput( + 'alice@example.com', + bip353DohEndpoint: endpoint, + httpClient: client, + ); + + expect(resolved.offer, _offer); + }); + + test('rejects BIP353 response without DNSSEC authentication', () async { + final client = MockClient( + (_) async => http.Response( + jsonEncode({ + 'Status': 0, + 'AD': false, + 'Answer': [ + {'type': 16, 'data': '"bitcoin:?lno=$_offer"'}, + ], + }), + 200, + ), + ); + + expect( + () => Bolt12WalletProvider.resolveInput( + 'alice@example.com', + httpClient: client, + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('DNSSEC'), + ), + ), + ); + }); + test('rejects malformed input', () async { expect( () => Bolt12WalletProvider.resolveInput('not a payment target'), diff --git a/packages/sample-app/pubspec.lock b/packages/sample-app/pubspec.lock index b41159cdd..2c0edb6b0 100644 --- a/packages/sample-app/pubspec.lock +++ b/packages/sample-app/pubspec.lock @@ -169,22 +169,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" - dart_bip353: - dependency: transitive - description: - name: dart_bip353 - sha256: "269daf722556e66c56cf62f7c7d584bd8f43e7480113f31001f382135f8899c5" - url: "https://pub.dev" - source: hosted - version: "0.8.0" - dart_bolt12_decoder: - dependency: transitive - description: - name: dart_bolt12_decoder - sha256: c61b34b6922c5f64d6f50d16c479bd3f0189a093557d59f4eb69424c51dd3d57 - url: "https://pub.dev" - source: hosted - version: "0.8.0" dbus: dependency: transitive description: From 4e293e945a74d2a2ae44c190548c8673c4e64323 Mon Sep 17 00:00:00 2001 From: fmar Date: Mon, 7 Sep 2026 14:45:46 +0200 Subject: [PATCH 10/22] fix case insensitive --- .../domain_layer/entities/wallet/bip321.dart | 10 ++++-- packages/ndk/test/entities/bip321_test.dart | 31 +++++++++++++++---- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/ndk/lib/domain_layer/entities/wallet/bip321.dart b/packages/ndk/lib/domain_layer/entities/wallet/bip321.dart index 49e68cc5c..704865d6f 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/bip321.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/bip321.dart @@ -26,7 +26,13 @@ class Bip321 { throw FormatException('BIP-321 URI must use the bitcoin scheme', payment); } - final requiredParameters = uri.queryParametersAll.keys.where( + final parameters = >{}; + for (final entry in uri.queryParametersAll.entries) { + final normalizedKey = entry.key.toLowerCase(); + (parameters[normalizedKey] ??= []).addAll(entry.value); + } + + final requiredParameters = parameters.keys.where( (key) => key.startsWith('req-'), ); if (requiredParameters.isNotEmpty) { @@ -36,7 +42,7 @@ class Bip321 { ); } - final instructions = uri.queryParametersAll['lightning']; + final instructions = parameters['lightning']; if (instructions == null || instructions.length != 1 || instructions.single.isEmpty) { diff --git a/packages/ndk/test/entities/bip321_test.dart b/packages/ndk/test/entities/bip321_test.dart index ab500e2b4..a672e00e6 100644 --- a/packages/ndk/test/entities/bip321_test.dart +++ b/packages/ndk/test/entities/bip321_test.dart @@ -12,6 +12,23 @@ void main() { expect(Bip321.getBolt11(payment), invoice); }); + test('matches lightning keys case-insensitively', () { + expect( + Bip321.getBolt11('bitcoin:?LIGHTNING=lnbc1uppercase'), + 'lnbc1uppercase', + ); + expect( + Bip321.getBolt11('bitcoin:?LiGhTnInG=lnbc1mixedcase'), + 'lnbc1mixedcase', + ); + expect( + () => Bip321.getBolt11( + 'bitcoin:?lightning=lnbc1first&LIGHTNING=lnbc1second', + ), + throwsFormatException, + ); + }); + test('decodes BOLT11 amounts in millisatoshis', () { expect(Bip321.getBolt11AmountMsat('lnbc1paymentdata'), isNull); expect(Bip321.getBolt11AmountMsat('lnbc210n1paymentdata'), 21000); @@ -22,12 +39,14 @@ void main() { }); test('rejects unknown required parameters', () { - expect( - () => Bip321.getBolt11( - 'bitcoin:?lightning=lnbc1paymentdata&req-example=value', - ), - throwsUnsupportedError, - ); + for (final requiredKey in ['req-example', 'REQ-EXAMPLE', 'ReQ-example']) { + expect( + () => Bip321.getBolt11( + 'bitcoin:?lightning=lnbc1paymentdata&$requiredKey=value', + ), + throwsUnsupportedError, + ); + } }); test('rejects missing and duplicate lightning instructions', () { From 9f03416c71cc43ea1dddb9c2b25b11470b4fdf56 Mon Sep 17 00:00:00 2001 From: fmar Date: Mon, 7 Sep 2026 14:52:16 +0200 Subject: [PATCH 11/22] fix i18n and enum --- .../providers/bolt12/bolt12_wallet.dart | 2 +- .../ndk/test/entities/bolt12_wallet_test.dart | 1 + .../lib/l10n/app_localizations_de.dart | 58 ++++++++++--------- .../lib/l10n/app_localizations_es.dart | 58 ++++++++++--------- .../lib/l10n/app_localizations_fi.dart | 58 ++++++++++--------- .../lib/l10n/app_localizations_fr.dart | 58 ++++++++++--------- 6 files changed, 122 insertions(+), 113 deletions(-) diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet.dart index 9cc23ac11..1c7f6a21d 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet.dart @@ -24,7 +24,6 @@ class Bolt12Wallet extends Wallet { Bolt12Wallet({ required super.id, required super.name, - super.type = WalletType.BOLT12, required super.supportedUnits, required this.offer, required this.source, @@ -39,6 +38,7 @@ class Bolt12Wallet extends Wallet { this.hasBlindedPaths = false, Map? metadata, }) : super( + type: WalletType.BOLT12, metadata: Map.unmodifiable({ ...(metadata ?? const {}), 'offer': offer, diff --git a/packages/ndk/test/entities/bolt12_wallet_test.dart b/packages/ndk/test/entities/bolt12_wallet_test.dart index 1fbe3fb63..00cfa0407 100644 --- a/packages/ndk/test/entities/bolt12_wallet_test.dart +++ b/packages/ndk/test/entities/bolt12_wallet_test.dart @@ -160,6 +160,7 @@ void main() { metadata: resolved.toMetadata(), ) as Bolt12Wallet; + expect(wallet.type, WalletType.BOLT12); expect(wallet.canReceive, isTrue); expect(wallet.canSend, isFalse); expect(await provider.receive(wallet, 123), _offer); diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_de.dart b/packages/ndk_flutter/lib/l10n/app_localizations_de.dart index 48a7536d0..5e30c3611 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_de.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_de.dart @@ -772,24 +772,26 @@ class AppLocalizationsDe extends AppLocalizations { String get payInvoiceTitle => 'Rechnung bezahlen'; @override - String get sendToWallet => 'Send to Wallet'; + String get sendToWallet => 'An eine Wallet senden'; @override - String get sendToWalletDescription => 'Transfer to another compatible wallet'; + String get sendToWalletDescription => + 'Auf eine andere kompatible Wallet übertragen'; @override - String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + String get noCompatibleReceivingWallets => + 'Keine kompatiblen Empfangs-Wallets'; @override String get noCompatibleReceivingWalletsDescription => - 'Add or connect another wallet that can receive a payment supported by this wallet.'; + 'Füge eine andere Wallet hinzu oder verbinde eine, die eine von dieser Wallet unterstützte Zahlung empfangen kann.'; @override - String get destinationWallet => 'Destination wallet'; + String get destinationWallet => 'Ziel-Wallet'; @override String walletTransferSubmitted(String walletName) { - return 'Payment sent to $walletName'; + return 'Zahlung an $walletName gesendet'; } @override @@ -1158,81 +1160,81 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get bolt12Wallet => 'BOLT12 Wallet'; + String get bolt12Wallet => 'BOLT12-Wallet'; @override - String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + String get bolt12WalletSubtitle => 'Wiederverwendbares Lightning-Angebot'; @override - String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + String get bolt12PrivateOfferSubtitle => 'Wiederverwendbares privates Angebot'; @override - String get anyAmount => 'Any amount'; + String get anyAmount => 'Beliebiger Betrag'; @override - String get blindedRoute => 'Blinded'; + String get blindedRoute => 'Verblindete Route'; @override String fromAmountSats(String amount) { - return 'From $amount sats'; + return 'Ab $amount Sats'; } @override String fromAmountMsats(String amount) { - return 'From $amount msats'; + return 'Ab $amount msats'; } @override String fromCurrencyAmount(String amount, String currency) { - return 'From $amount $currency'; + return 'Ab $amount $currency'; } @override String bolt12Expires(String date) { - return 'Expires $date'; + return 'Läuft am $date ab'; } @override - String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + String get bolt12WalletTypeTitle => 'BOLT12-Angebot'; @override String get bolt12WalletTypeSubtitle => - 'Receive-only wallet using a reusable offer'; + 'Nur-Empfangs-Wallet mit einem wiederverwendbaren Angebot'; @override - String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + String get addBolt12WalletTitle => 'BOLT12-Wallet hinzufügen'; @override String get enterBolt12Input => - 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + 'Gib ein lno-Angebot, eine bitcoin:?lno=...-URI oder eine BIP353-Adresse ein oder scanne sie.'; @override - String get bolt12Input => 'BOLT12 payment target'; + String get bolt12Input => 'BOLT12-Zahlungsziel'; @override - String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + String get bolt12InputHint => 'lno1..., bitcoin:?lno=... oder user@domain.com'; @override - String get walletNameOptional => 'Wallet name (optional)'; + String get walletNameOptional => 'Wallet-Name (optional)'; @override - String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + String get scanBolt12QrCodeTitle => 'BOLT12-QR-Code scannen'; @override String get invalidBolt12QrCode => - 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + 'Der QR-Code ist kein BOLT12-, BIP321- oder BIP353-Zahlungsziel.'; @override String get pleaseEnterBolt12Input => - 'Please enter a BOLT12 offer or BIP353 address.'; + 'Bitte gib ein BOLT12-Angebot oder eine BIP353-Adresse ein.'; @override - String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + String get bolt12WalletAdded => 'BOLT12-Wallet erfolgreich hinzugefügt!'; @override - String get bolt12OfferTitle => 'Receive with BOLT12'; + String get bolt12OfferTitle => 'Mit BOLT12 empfangen'; @override String get bolt12OfferInstructions => - 'Share this reusable offer to receive a Lightning payment.'; + 'Teile dieses wiederverwendbare Angebot, um eine Lightning-Zahlung zu empfangen.'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_es.dart b/packages/ndk_flutter/lib/l10n/app_localizations_es.dart index 3af077a19..5c813945e 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_es.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_es.dart @@ -774,24 +774,26 @@ class AppLocalizationsEs extends AppLocalizations { String get payInvoiceTitle => 'Pagar Factura'; @override - String get sendToWallet => 'Send to Wallet'; + String get sendToWallet => 'Enviar a una cartera'; @override - String get sendToWalletDescription => 'Transfer to another compatible wallet'; + String get sendToWalletDescription => + 'Transferir a otra cartera compatible'; @override - String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + String get noCompatibleReceivingWallets => + 'No hay carteras receptoras compatibles'; @override String get noCompatibleReceivingWalletsDescription => - 'Add or connect another wallet that can receive a payment supported by this wallet.'; + 'Añade o conecta otra cartera que pueda recibir un pago admitido por esta cartera.'; @override - String get destinationWallet => 'Destination wallet'; + String get destinationWallet => 'Cartera de destino'; @override String walletTransferSubmitted(String walletName) { - return 'Payment sent to $walletName'; + return 'Pago enviado a $walletName'; } @override @@ -1159,81 +1161,81 @@ class AppLocalizationsEs extends AppLocalizations { } @override - String get bolt12Wallet => 'BOLT12 Wallet'; + String get bolt12Wallet => 'Cartera BOLT12'; @override - String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + String get bolt12WalletSubtitle => 'Oferta Lightning reutilizable'; @override - String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + String get bolt12PrivateOfferSubtitle => 'Oferta privada reutilizable'; @override - String get anyAmount => 'Any amount'; + String get anyAmount => 'Cualquier importe'; @override - String get blindedRoute => 'Blinded'; + String get blindedRoute => 'Ruta cegada'; @override String fromAmountSats(String amount) { - return 'From $amount sats'; + return 'Desde $amount sats'; } @override String fromAmountMsats(String amount) { - return 'From $amount msats'; + return 'Desde $amount msats'; } @override String fromCurrencyAmount(String amount, String currency) { - return 'From $amount $currency'; + return 'Desde $amount $currency'; } @override String bolt12Expires(String date) { - return 'Expires $date'; + return 'Caduca el $date'; } @override - String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + String get bolt12WalletTypeTitle => 'Oferta BOLT12'; @override String get bolt12WalletTypeSubtitle => - 'Receive-only wallet using a reusable offer'; + 'Cartera de solo recepción que usa una oferta reutilizable'; @override - String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + String get addBolt12WalletTitle => 'Añadir cartera BOLT12'; @override String get enterBolt12Input => - 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + 'Introduce o escanea una oferta lno, un URI bitcoin:?lno=... o una dirección BIP353.'; @override - String get bolt12Input => 'BOLT12 payment target'; + String get bolt12Input => 'Destino de pago BOLT12'; @override - String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + String get bolt12InputHint => 'lno1..., bitcoin:?lno=... o user@domain.com'; @override - String get walletNameOptional => 'Wallet name (optional)'; + String get walletNameOptional => 'Nombre de la cartera (opcional)'; @override - String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + String get scanBolt12QrCodeTitle => 'Escanear código QR BOLT12'; @override String get invalidBolt12QrCode => - 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + 'El código QR no es un destino de pago BOLT12, BIP321 ni BIP353.'; @override String get pleaseEnterBolt12Input => - 'Please enter a BOLT12 offer or BIP353 address.'; + 'Introduce una oferta BOLT12 o una dirección BIP353.'; @override - String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + String get bolt12WalletAdded => '¡Cartera BOLT12 añadida correctamente!'; @override - String get bolt12OfferTitle => 'Receive with BOLT12'; + String get bolt12OfferTitle => 'Recibir con BOLT12'; @override String get bolt12OfferInstructions => - 'Share this reusable offer to receive a Lightning payment.'; + 'Comparte esta oferta reutilizable para recibir un pago Lightning.'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart b/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart index 02136ee93..28a480fd3 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart @@ -772,24 +772,26 @@ class AppLocalizationsFi extends AppLocalizations { String get payInvoiceTitle => 'Maksa lasku'; @override - String get sendToWallet => 'Send to Wallet'; + String get sendToWallet => 'Lähetä lompakkoon'; @override - String get sendToWalletDescription => 'Transfer to another compatible wallet'; + String get sendToWalletDescription => + 'Siirrä toiseen yhteensopivaan lompakkoon'; @override - String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + String get noCompatibleReceivingWallets => + 'Ei yhteensopivia vastaanottavia lompakoita'; @override String get noCompatibleReceivingWalletsDescription => - 'Add or connect another wallet that can receive a payment supported by this wallet.'; + 'Lisää tai yhdistä toinen lompakko, joka voi vastaanottaa tämän lompakon tukeman maksun.'; @override - String get destinationWallet => 'Destination wallet'; + String get destinationWallet => 'Kohdelompakko'; @override String walletTransferSubmitted(String walletName) { - return 'Payment sent to $walletName'; + return 'Maksu lähetetty lompakkoon $walletName'; } @override @@ -1156,81 +1158,81 @@ class AppLocalizationsFi extends AppLocalizations { } @override - String get bolt12Wallet => 'BOLT12 Wallet'; + String get bolt12Wallet => 'BOLT12-lompakko'; @override - String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + String get bolt12WalletSubtitle => 'Uudelleenkäytettävä Lightning-tarjous'; @override - String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + String get bolt12PrivateOfferSubtitle => 'Uudelleenkäytettävä yksityinen tarjous'; @override - String get anyAmount => 'Any amount'; + String get anyAmount => 'Mikä tahansa summa'; @override - String get blindedRoute => 'Blinded'; + String get blindedRoute => 'Sokaisettu reitti'; @override String fromAmountSats(String amount) { - return 'From $amount sats'; + return 'Alkaen $amount sats'; } @override String fromAmountMsats(String amount) { - return 'From $amount msats'; + return 'Alkaen $amount msats'; } @override String fromCurrencyAmount(String amount, String currency) { - return 'From $amount $currency'; + return 'Alkaen $amount $currency'; } @override String bolt12Expires(String date) { - return 'Expires $date'; + return 'Vanhenee $date'; } @override - String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + String get bolt12WalletTypeTitle => 'BOLT12-tarjous'; @override String get bolt12WalletTypeSubtitle => - 'Receive-only wallet using a reusable offer'; + 'Vain vastaanottamiseen tarkoitettu lompakko, joka käyttää uudelleenkäytettävää tarjousta'; @override - String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + String get addBolt12WalletTitle => 'Lisää BOLT12-lompakko'; @override String get enterBolt12Input => - 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + 'Syötä tai skannaa lno-tarjous, bitcoin:?lno=...-URI tai BIP353-osoite.'; @override - String get bolt12Input => 'BOLT12 payment target'; + String get bolt12Input => 'BOLT12-maksukohde'; @override - String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + String get bolt12InputHint => 'lno1..., bitcoin:?lno=... tai user@domain.com'; @override - String get walletNameOptional => 'Wallet name (optional)'; + String get walletNameOptional => 'Lompakon nimi (valinnainen)'; @override - String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + String get scanBolt12QrCodeTitle => 'Skannaa BOLT12-QR-koodi'; @override String get invalidBolt12QrCode => - 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + 'QR-koodi ei ole BOLT12-, BIP321- tai BIP353-maksukohde.'; @override String get pleaseEnterBolt12Input => - 'Please enter a BOLT12 offer or BIP353 address.'; + 'Syötä BOLT12-tarjous tai BIP353-osoite.'; @override - String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + String get bolt12WalletAdded => 'BOLT12-lompakko lisättiin onnistuneesti!'; @override - String get bolt12OfferTitle => 'Receive with BOLT12'; + String get bolt12OfferTitle => 'Vastaanota BOLT12:lla'; @override String get bolt12OfferInstructions => - 'Share this reusable offer to receive a Lightning payment.'; + 'Jaa tämä uudelleenkäytettävä tarjous vastaanottaaksesi Lightning-maksun.'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart b/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart index eed077410..7174097b9 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart @@ -773,24 +773,26 @@ class AppLocalizationsFr extends AppLocalizations { String get payInvoiceTitle => 'Payer la Facture'; @override - String get sendToWallet => 'Send to Wallet'; + String get sendToWallet => 'Envoyer vers un portefeuille'; @override - String get sendToWalletDescription => 'Transfer to another compatible wallet'; + String get sendToWalletDescription => + 'Transférer vers un autre portefeuille compatible'; @override - String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + String get noCompatibleReceivingWallets => + 'Aucun portefeuille de réception compatible'; @override String get noCompatibleReceivingWalletsDescription => - 'Add or connect another wallet that can receive a payment supported by this wallet.'; + 'Ajoutez ou connectez un autre portefeuille capable de recevoir un paiement pris en charge par ce portefeuille.'; @override - String get destinationWallet => 'Destination wallet'; + String get destinationWallet => 'Portefeuille de destination'; @override String walletTransferSubmitted(String walletName) { - return 'Payment sent to $walletName'; + return 'Paiement envoyé à $walletName'; } @override @@ -1159,81 +1161,81 @@ class AppLocalizationsFr extends AppLocalizations { } @override - String get bolt12Wallet => 'BOLT12 Wallet'; + String get bolt12Wallet => 'Portefeuille BOLT12'; @override - String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + String get bolt12WalletSubtitle => 'Offre Lightning réutilisable'; @override - String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + String get bolt12PrivateOfferSubtitle => 'Offre privée réutilisable'; @override - String get anyAmount => 'Any amount'; + String get anyAmount => 'N\'importe quel montant'; @override - String get blindedRoute => 'Blinded'; + String get blindedRoute => 'Route aveuglée'; @override String fromAmountSats(String amount) { - return 'From $amount sats'; + return 'À partir de $amount sats'; } @override String fromAmountMsats(String amount) { - return 'From $amount msats'; + return 'À partir de $amount msats'; } @override String fromCurrencyAmount(String amount, String currency) { - return 'From $amount $currency'; + return 'À partir de $amount $currency'; } @override String bolt12Expires(String date) { - return 'Expires $date'; + return 'Expire le $date'; } @override - String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + String get bolt12WalletTypeTitle => 'Offre BOLT12'; @override String get bolt12WalletTypeSubtitle => - 'Receive-only wallet using a reusable offer'; + 'Portefeuille de réception uniquement utilisant une offre réutilisable'; @override - String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + String get addBolt12WalletTitle => 'Ajouter un portefeuille BOLT12'; @override String get enterBolt12Input => - 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + 'Saisissez ou scannez une offre lno, un URI bitcoin:?lno=... ou une adresse BIP353.'; @override - String get bolt12Input => 'BOLT12 payment target'; + String get bolt12Input => 'Destination de paiement BOLT12'; @override - String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + String get bolt12InputHint => 'lno1..., bitcoin:?lno=... ou user@domain.com'; @override - String get walletNameOptional => 'Wallet name (optional)'; + String get walletNameOptional => 'Nom du portefeuille (facultatif)'; @override - String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + String get scanBolt12QrCodeTitle => 'Scanner le code QR BOLT12'; @override String get invalidBolt12QrCode => - 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + 'Le code QR ne correspond pas à une destination de paiement BOLT12, BIP321 ou BIP353.'; @override String get pleaseEnterBolt12Input => - 'Please enter a BOLT12 offer or BIP353 address.'; + 'Veuillez saisir une offre BOLT12 ou une adresse BIP353.'; @override - String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + String get bolt12WalletAdded => 'Portefeuille BOLT12 ajouté avec succès !'; @override - String get bolt12OfferTitle => 'Receive with BOLT12'; + String get bolt12OfferTitle => 'Recevoir avec BOLT12'; @override String get bolt12OfferInstructions => - 'Share this reusable offer to receive a Lightning payment.'; + 'Partagez cette offre réutilisable pour recevoir un paiement Lightning.'; } From ee54edf2ec2866ff2802cb89d8876d84b0c47272 Mon Sep 17 00:00:00 2001 From: fmar Date: Mon, 7 Sep 2026 14:54:53 +0200 Subject: [PATCH 12/22] fix removal of offerId --- packages/drift/test/drift_cache_manager_test.dart | 1 - packages/ndk_flutter/lib/widgets/wallets/n_wallet_card.dart | 1 - packages/ndk_flutter/lib/widgets/wallets/n_wallet_card_list.dart | 1 - 3 files changed, 3 deletions(-) diff --git a/packages/drift/test/drift_cache_manager_test.dart b/packages/drift/test/drift_cache_manager_test.dart index ac7a4931a..0ccb27144 100644 --- a/packages/drift/test/drift_cache_manager_test.dart +++ b/packages/drift/test/drift_cache_manager_test.dart @@ -18,7 +18,6 @@ void main() { source: 'alice@example.com', bip353Address: 'alice@example.com', description: 'Test offer', - offerId: 'offer-id', issuer: 'Test issuer', currency: 'USD', expiresAt: 2000000000, 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 d3f26a9d9..d3450a350 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card.dart @@ -1061,7 +1061,6 @@ class _NWalletCardState extends State bip353Address: w.bip353Address, description: w.description, nodeId: w.nodeId, - offerId: w.offerId, amount: w.amount, issuer: w.issuer, currency: w.currency, diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card_list.dart b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card_list.dart index 583f92c19..1b71d67e2 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card_list.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card_list.dart @@ -149,7 +149,6 @@ class _NWalletCardListState extends State { bip353Address: wallet.bip353Address, description: wallet.description, nodeId: wallet.nodeId, - offerId: wallet.offerId, amount: wallet.amount, issuer: wallet.issuer, currency: wallet.currency, From 64b3d203d73b53dca3a3b9189717ec7dc7e9cc1c Mon Sep 17 00:00:00 2001 From: fmar Date: Mon, 7 Sep 2026 14:58:06 +0200 Subject: [PATCH 13/22] format --- packages/ndk/example/wallets/send.dart | 3 ++- packages/ndk/test/entities/bolt12_wallet_test.dart | 3 +-- packages/ndk_flutter/lib/l10n/app_localizations_de.dart | 6 ++++-- packages/ndk_flutter/lib/l10n/app_localizations_es.dart | 3 +-- packages/ndk_flutter/lib/l10n/app_localizations_fi.dart | 3 ++- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/ndk/example/wallets/send.dart b/packages/ndk/example/wallets/send.dart index 87c666f7e..41995571d 100644 --- a/packages/ndk/example/wallets/send.dart +++ b/packages/ndk/example/wallets/send.dart @@ -30,7 +30,8 @@ Future main() async { final walletId = Platform.environment['WALLET_ID'] ?? wallets.first.id; - final result = await ndk.wallets.payBip321(walletId: walletId, payment: payment, amountMsat: amountSats * 1000); + final result = await ndk.wallets.payBip321( + walletId: walletId, payment: payment, amountMsat: amountSats * 1000); print('Payment result:'); print('- preimage: ${result.preimage}'); diff --git a/packages/ndk/test/entities/bolt12_wallet_test.dart b/packages/ndk/test/entities/bolt12_wallet_test.dart index 00cfa0407..c404423ea 100644 --- a/packages/ndk/test/entities/bolt12_wallet_test.dart +++ b/packages/ndk/test/entities/bolt12_wallet_test.dart @@ -94,8 +94,7 @@ void main() { 'Answer': [ { 'type': 16, - 'data': - '"bitcoin:?lno=${_offer.substring(0, 60)}" ' + 'data': '"bitcoin:?lno=${_offer.substring(0, 60)}" ' '"${_offer.substring(60)}"', }, ], diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_de.dart b/packages/ndk_flutter/lib/l10n/app_localizations_de.dart index 5e30c3611..47592bc49 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_de.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_de.dart @@ -1166,7 +1166,8 @@ class AppLocalizationsDe extends AppLocalizations { String get bolt12WalletSubtitle => 'Wiederverwendbares Lightning-Angebot'; @override - String get bolt12PrivateOfferSubtitle => 'Wiederverwendbares privates Angebot'; + String get bolt12PrivateOfferSubtitle => + 'Wiederverwendbares privates Angebot'; @override String get anyAmount => 'Beliebiger Betrag'; @@ -1212,7 +1213,8 @@ class AppLocalizationsDe extends AppLocalizations { String get bolt12Input => 'BOLT12-Zahlungsziel'; @override - String get bolt12InputHint => 'lno1..., bitcoin:?lno=... oder user@domain.com'; + String get bolt12InputHint => + 'lno1..., bitcoin:?lno=... oder user@domain.com'; @override String get walletNameOptional => 'Wallet-Name (optional)'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_es.dart b/packages/ndk_flutter/lib/l10n/app_localizations_es.dart index 5c813945e..018c69aab 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_es.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_es.dart @@ -777,8 +777,7 @@ class AppLocalizationsEs extends AppLocalizations { String get sendToWallet => 'Enviar a una cartera'; @override - String get sendToWalletDescription => - 'Transferir a otra cartera compatible'; + String get sendToWalletDescription => 'Transferir a otra cartera compatible'; @override String get noCompatibleReceivingWallets => diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart b/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart index 28a480fd3..6fdf86e12 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart @@ -1164,7 +1164,8 @@ class AppLocalizationsFi extends AppLocalizations { String get bolt12WalletSubtitle => 'Uudelleenkäytettävä Lightning-tarjous'; @override - String get bolt12PrivateOfferSubtitle => 'Uudelleenkäytettävä yksityinen tarjous'; + String get bolt12PrivateOfferSubtitle => + 'Uudelleenkäytettävä yksityinen tarjous'; @override String get anyAmount => 'Mikä tahansa summa'; From 0d340baa496caafe8a215f050174ad772191ef18 Mon Sep 17 00:00:00 2001 From: fmar Date: Wed, 9 Sep 2026 12:55:52 +0200 Subject: [PATCH 14/22] fix: add tbs --- packages/ndk/lib/domain_layer/entities/wallet/bip321.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ndk/lib/domain_layer/entities/wallet/bip321.dart b/packages/ndk/lib/domain_layer/entities/wallet/bip321.dart index 704865d6f..e92dab7a3 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/bip321.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/bip321.dart @@ -67,7 +67,7 @@ class Bip321 { final hrp = invoice.toLowerCase().substring(0, separator); final match = RegExp( - r'^ln(?:bcrt|bc|tb|sb)([0-9]*)([munp]?)$', + r'^ln(?:bcrt|tbs|bc|tb|sb)([0-9]*)([munp]?)$', ).firstMatch(hrp); if (match == null) { throw const FormatException('Invalid BOLT11 invoice prefix'); From 5147eba8f95b61aea2815c7c1a040c7610474c70 Mon Sep 17 00:00:00 2001 From: fmar Date: Wed, 9 Sep 2026 12:56:36 +0200 Subject: [PATCH 15/22] fix: missing amount in bip321 --- .../bolt12/bolt12_wallet_provider.dart | 30 +++++++++++++++++-- packages/ndk/test/entities/bip321_test.dart | 5 ++++ .../ndk/test/entities/bolt12_wallet_test.dart | 13 +++++++- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet_provider.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet_provider.dart index 3f3986ca4..52f201769 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet_provider.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet_provider.dart @@ -325,8 +325,16 @@ class Bolt12WalletProvider implements WalletProvider { } @override - Future receive(Wallet wallet, int amountSats) async => - (wallet as Bolt12Wallet).offer; + Future receive(Wallet wallet, int amountSats) async { + final offer = (wallet as Bolt12Wallet).offer; + return Uri( + scheme: 'bitcoin', + queryParameters: { + 'amount': _formatBitcoinAmount(amountSats, 100000000), + 'lno': offer, + }, + ).toString(); + } @override Future payBip321( @@ -360,11 +368,27 @@ class Bolt12WalletProvider implements WalletProvider { resultType: 'receive', bip321: Uri( scheme: 'bitcoin', - queryParameters: {'lno': offer}, + queryParameters: { + if (amountMsat != null) + 'amount': _formatBitcoinAmount(amountMsat, 100000000000), + 'lno': offer, + }, ).toString(), ); } + static String _formatBitcoinAmount(int amount, int unitsPerBitcoin) { + final whole = amount ~/ unitsPerBitcoin; + final remainder = amount % unitsPerBitcoin; + if (remainder == 0) return whole.toString(); + + final fraction = remainder + .toString() + .padLeft(unitsPerBitcoin.toString().length - 1, '0') + .replaceFirst(RegExp(r'0+$'), ''); + return '$whole.$fraction'; + } + @override Stream> get discoveredWallets => Stream.value([]); } diff --git a/packages/ndk/test/entities/bip321_test.dart b/packages/ndk/test/entities/bip321_test.dart index a672e00e6..2457fdb55 100644 --- a/packages/ndk/test/entities/bip321_test.dart +++ b/packages/ndk/test/entities/bip321_test.dart @@ -38,6 +38,11 @@ void main() { expect(Bip321.getBolt11AmountMsat('lnsb50p1paymentdata'), 5); }); + test('decodes signet BOLT11 amounts in millisatoshis', () { + expect(Bip321.getBolt11AmountMsat('lntbs1paymentdata'), isNull); + expect(Bip321.getBolt11AmountMsat('lntbs5u1paymentdata'), 500000); + }); + test('rejects unknown required parameters', () { for (final requiredKey in ['req-example', 'REQ-EXAMPLE', 'ReQ-example']) { expect( diff --git a/packages/ndk/test/entities/bolt12_wallet_test.dart b/packages/ndk/test/entities/bolt12_wallet_test.dart index c404423ea..9f620113f 100644 --- a/packages/ndk/test/entities/bolt12_wallet_test.dart +++ b/packages/ndk/test/entities/bolt12_wallet_test.dart @@ -162,9 +162,20 @@ void main() { expect(wallet.type, WalletType.BOLT12); expect(wallet.canReceive, isTrue); expect(wallet.canSend, isFalse); - expect(await provider.receive(wallet, 123), _offer); + expect( + await provider.receive(wallet, 123), + 'bitcoin:?amount=0.00000123&lno=$_offer', + ); final bip321 = await provider.receiveBip321(wallet); expect(bip321.bip321, 'bitcoin:?lno=$_offer'); + final bip321WithAmount = await provider.receiveBip321( + wallet, + amountMsat: 123001, + ); + expect( + bip321WithAmount.bip321, + 'bitcoin:?amount=0.00000123001&lno=$_offer', + ); expect( () => provider.send(wallet, 'lnbc...'), throwsA(isA()), From 0289ce57f9a8eb9d29bca5faf97d9f43748c00c8 Mon Sep 17 00:00:00 2001 From: fmar Date: Sat, 12 Sep 2026 04:00:26 +0200 Subject: [PATCH 16/22] better wallet add UI/UX + cashu mint recommendations / nip-87 --- doc/ndk_flutter/qr-scanner.md | 116 +- .../cashu/cashu_mint_recommendation.dart | 43 + .../domain_layer/usecases/cashu/cashu.dart | 53 +- .../cashu/cashu_mint_recommendations.dart | 181 ++ packages/ndk/lib/entities.dart | 1 + packages/ndk/lib/ndk.dart | 2 + packages/ndk/lib/presentation_layer/init.dart | 2 + .../cashu_mint_recommendations_test.dart | 92 + .../ndk_flutter/assets/images/albyhub.svg | 4 + packages/ndk_flutter/assets/images/coinos.svg | 1 + packages/ndk_flutter/lib/l10n/app_de.arb | 75 + packages/ndk_flutter/lib/l10n/app_en.arb | 122 +- packages/ndk_flutter/lib/l10n/app_es.arb | 75 + packages/ndk_flutter/lib/l10n/app_fi.arb | 75 + packages/ndk_flutter/lib/l10n/app_fr.arb | 75 + packages/ndk_flutter/lib/l10n/app_it.arb | 75 + packages/ndk_flutter/lib/l10n/app_ja.arb | 75 + .../lib/l10n/app_localizations.dart | 390 +++ .../lib/l10n/app_localizations_de.dart | 260 +- .../lib/l10n/app_localizations_en.dart | 211 ++ .../lib/l10n/app_localizations_es.dart | 257 +- .../lib/l10n/app_localizations_fi.dart | 260 +- .../lib/l10n/app_localizations_fr.dart | 266 +- .../lib/l10n/app_localizations_it.dart | 236 +- .../lib/l10n/app_localizations_ja.dart | 229 +- .../lib/l10n/app_localizations_pl.dart | 236 +- .../lib/l10n/app_localizations_pt.dart | 485 +++- .../lib/l10n/app_localizations_ru.dart | 236 +- .../lib/l10n/app_localizations_sk.dart | 235 +- .../lib/l10n/app_localizations_zh.dart | 228 +- packages/ndk_flutter/lib/l10n/app_pl.arb | 75 + packages/ndk_flutter/lib/l10n/app_pt.arb | 75 + packages/ndk_flutter/lib/l10n/app_pt_BR.arb | 75 + packages/ndk_flutter/lib/l10n/app_ru.arb | 75 + packages/ndk_flutter/lib/l10n/app_sk.arb | 75 + packages/ndk_flutter/lib/l10n/app_zh.arb | 75 + .../widgets/wallets/n_add_wallet_dialogs.dart | 2164 +++++++++++++++-- .../widgets/wallets/n_cashu_mint_icon.dart | 45 + .../lib/widgets/wallets/n_wallet_actions.dart | 65 +- .../lib/widgets/wallets/n_wallet_card.dart | 8 +- .../lib/widgets/wallets/n_wallets.dart | 31 +- .../wallets/wallet_action_dialogs.dart | 49 +- packages/ndk_flutter/pubspec.yaml | 1 + .../test/wallet_input_classifier_test.dart | 103 + packages/sample-app/ios/Runner/Info.plist | 2 +- packages/sample-app/lib/login_popup.dart | 2 +- packages/sample-app/lib/main.dart | 8 +- packages/sample-app/lib/nwc_qr_scanner.dart | 1292 +++++++++- packages/sample-app/lib/wallets.dart | 60 +- .../sample-app/lib/widgets_demo_page.dart | 2 +- packages/sample-app/pubspec.lock | 40 + packages/sample-app/pubspec.yaml | 1 + 52 files changed, 8316 insertions(+), 603 deletions(-) create mode 100644 packages/ndk/lib/domain_layer/entities/cashu/cashu_mint_recommendation.dart create mode 100644 packages/ndk/lib/domain_layer/usecases/cashu/cashu_mint_recommendations.dart create mode 100644 packages/ndk/test/cashu/cashu_mint_recommendations_test.dart create mode 100644 packages/ndk_flutter/assets/images/albyhub.svg create mode 100644 packages/ndk_flutter/assets/images/coinos.svg create mode 100644 packages/ndk_flutter/lib/widgets/wallets/n_cashu_mint_icon.dart create mode 100644 packages/ndk_flutter/test/wallet_input_classifier_test.dart diff --git a/doc/ndk_flutter/qr-scanner.md b/doc/ndk_flutter/qr-scanner.md index bbc861072..5fce8991d 100644 --- a/doc/ndk_flutter/qr-scanner.md +++ b/doc/ndk_flutter/qr-scanner.md @@ -6,36 +6,125 @@ order: 90 # QR scanner -Some `ndk_flutter` widgets can read a value from a QR code. For example, the wallet widgets -let the user scan an NWC connection URI (`nostr+walletconnect://...`) instead of typing it. +The add-wallet flow accepts NWC connection URIs, Lightning and BIP353 addresses, BOLT12 +offers, BIP321 URIs, and HTTPS Cashu mint URLs from one QR scanner. `ndk_flutter` does **not** bundle a camera/scanner dependency. Instead you provide your own scanner, so you stay in control of the camera plugin, runtime permissions, and the UI. When you don't provide one, the scan button is hidden and users can still paste a value manually. +On Android and iOS the scanner opens when the user enters the add-wallet flow. Set +`openScannerOnAdd: false` to start on the unified choices screen instead. Your scanner remains +responsible for camera-permission rationale and denial handling. ## Provide a scanner -A scanner is a callback that opens your scanning UI and returns the scanned string, or `null` -if the user cancels: +A scanner is a callback that opens your scanning UI and returns a scanned value, a launched +wallet connection, or `null` if the user cancels: ```dart -typedef NwcUriScanner = Future Function(BuildContext context); +typedef WalletInputScanner = Future Function( + BuildContext context, + WalletInputScannerConfiguration configuration, +); ``` Pass it to the widget (or dialog) that needs it. For wallets, that's `NWallets`: :::code source="../../packages/sample-app/lib/wallets.dart" language="dart" range="72-76" title="wire a scanner into NWallets" ::: -The same `nwcUriScanner:` parameter is accepted by the standalone add-wallet dialogs: +Pass the scanner to the unified dialog directly when you do not use `NWallets`: ```dart -showAddNwcWalletDialog(context, ndkFlutter, nwcUriScanner: scanNwcUri); -showNwcConnectionOptionsDialog(context, ndkFlutter, nwcUriScanner: scanNwcUri); +showAddWalletTypeDialog( + context, + ndkFlutter, + walletInputScanner: scanWalletInput, +); ``` -The widgets validate the scanned value (e.g. that an NWC URI starts with -`nostr+walletconnect://`) and show an error for anything else, so your callback only needs to -return the raw scanned string. +Show `configuration.supportedInputDescription` in the scanner so users know which QR codes +work. Render `configuration.connectionOptions` beside camera and paste controls to expose the +standard installed-wallet chooser, Alby Go, custom providers, and web-wallet integrations +directly from the scanner. Listen to `configuration.connectionState` while an external flow is +active. Keep the scanner open through `awaitingReturn`, `connecting`, and `failed`; use +`retryPendingConnection` and `cancelPendingConnection` for retry and back actions. Return +`WalletInputScanResult.connectionStarted()` after the state reaches `connected`. + +The widget classifies and validates scanned values. Your callback returns +Return scanned values as `WalletInputScanResult.value(rawText)`. For pasted or +typed values, pass `manuallyEntered: true` so confirmation allows editing. +Legacy `nwcUriScanner` and `bolt12InputScanner` callbacks remain available on their +type-specific dialogs. + +## Wallet-assisted NWC connections + +On Android and iOS, the unified flow includes the standard NWC wallet chooser and Alby Go. +Add installed or web wallet integrations with `nwcConnectionOptions`: + +```dart +NWallets( + ndkFlutter: ndkFlutter, + walletInputScanner: scanWalletInput, + nwcConnectionOptions: [ + NwcConnectionOption( + label: 'My web wallet', + subtitle: 'Approve the connection in your browser', + connect: (context, ndkFlutter, coordinator) { + const callback = 'myapp://nwc'; + return coordinator.connectWithUri( + context, + launchUri: Uri.parse( + 'https://wallet.example/connect?callback=myapp%3A%2F%2Fnwc', + ), + callback: callback, + walletName: 'My web wallet', + ); + }, + ), + ], +) +``` + +Client-key web wallets can receive configurable app metadata and a freshly generated public +key. Complete pending authorization when the host app resumes: + +```dart +NwcConnectionOption( + id: 'coinos', + label: 'Coinos', + connect: (context, ndkFlutter, coordinator) { + return coordinator.connectWebWalletAuth( + context, + authorizationEndpoint: Uri.parse('https://coinos.io/apps/new'), + appName: 'My app', + discoveryRelay: 'wss://relay.coinos.io', + callback: 'myapp://nwc', + walletName: 'Coinos', + walletServicePubkey: + 'ba80990666ef0b6f4ba5059347beb13242921e54669e680064ca755256a1e3a6', + ); + }, +) +``` + +Call `NWalletsState.resumePendingWalletAuth()` from the app's resumed lifecycle callback. + +Use the standard installed-wallet flow for any app that handles `nostr+walletauth://`: + +```dart +return coordinator.connectWalletAuth( + context, + config: const AlbyGoConnectConfig( + appName: 'My app', + appIconUrl: 'https://example.com/icon.png', + callback: 'myapp://nwc', + ), + walletName: 'NWC', +); +``` + +Forward callback URLs to `NWalletsState.onProtocolUrlReceived`. Provider authorization URLs +must return a `nostr+walletconnect://` value in a callback query parameter. ## Example: scanning with mobile_scanner @@ -49,8 +138,9 @@ flutter pub add mobile_scanner The callback just opens a dialog that wraps the camera view and pops the first decoded value: -:::code source="../../packages/sample-app/lib/nwc_qr_scanner.dart" language="dart" range="8-13" title="scanNwcUri callback" ::: +:::code source="../../packages/sample-app/lib/nwc_qr_scanner.dart" language="dart" range="7-12" title="scanWalletInput callback" ::: The full dialog lives in [`packages/sample-app/lib/nwc_qr_scanner.dart`](https://github.com/relaystr/ndk/blob/master/packages/sample-app/lib/nwc_qr_scanner.dart). -It adds a camera preview, a paste fallback, error handling, and a desktop/web-safe layout. +It adds a camera preview, paste and manual-entry fallbacks, wallet connection choices, error +handling, and a desktop/web-safe layout. diff --git a/packages/ndk/lib/domain_layer/entities/cashu/cashu_mint_recommendation.dart b/packages/ndk/lib/domain_layer/entities/cashu/cashu_mint_recommendation.dart new file mode 100644 index 000000000..dfe971f59 --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_mint_recommendation.dart @@ -0,0 +1,43 @@ +import 'cashu_mint_info.dart'; + +/// Community information for a Cashu mint, aggregated from NIP-87 events. +class CashuMintRecommendation { + final String url; + final CashuMintInfo? mintInfo; + final double? averageRating; + final int reviewsCount; + final List reviews; + + const CashuMintRecommendation({ + required this.url, + this.mintInfo, + required this.averageRating, + required this.reviewsCount, + this.reviews = const [], + }); + + CashuMintRecommendation copyWith({CashuMintInfo? mintInfo}) { + return CashuMintRecommendation( + url: url, + mintInfo: mintInfo ?? this.mintInfo, + averageRating: averageRating, + reviewsCount: reviewsCount, + reviews: reviews, + ); + } +} + +/// Latest NIP-87 review published by one reviewer for one Cashu mint. +class CashuMintReview { + final String reviewerPubkey; + final int createdAt; + final int? rating; + final String comment; + + const CashuMintReview({ + required this.reviewerPubkey, + required this.createdAt, + required this.rating, + required this.comment, + }); +} diff --git a/packages/ndk/lib/domain_layer/usecases/cashu/cashu.dart b/packages/ndk/lib/domain_layer/usecases/cashu/cashu.dart index 5485fb758..f1b47d464 100644 --- a/packages/ndk/lib/domain_layer/usecases/cashu/cashu.dart +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu.dart @@ -7,6 +7,7 @@ import '../../entities/cashu/cashu_blinded_message.dart'; import '../../entities/cashu/cashu_blinded_signature.dart'; import '../../entities/cashu/cashu_mint_balance.dart'; import '../../entities/cashu/cashu_mint_info.dart'; +import '../../entities/cashu/cashu_mint_recommendation.dart'; import '../../entities/cashu/cashu_proof.dart'; import '../../entities/cashu/cashu_quote.dart'; import '../../entities/cashu/cashu_restore_result.dart'; @@ -23,6 +24,7 @@ import 'cashu_export_import.dart'; import 'cashu_bdhke.dart'; import 'cashu_cache_decorator.dart'; import 'cashu_keysets.dart'; +import 'cashu_mint_recommendations.dart'; import 'cashu_proof_select.dart'; import 'cashu_restore.dart'; import 'cashu_seed.dart'; @@ -41,6 +43,7 @@ class Cashu { late final CashuSeed _cashuSeed; late final CashuStateExportImport _cashuExportImport; + final CashuMintRecommendations? _mintRecommendations; final CashuKeyDerivation _cashuKeyDerivation; @@ -49,11 +52,13 @@ class Cashu { required WalletsRepo walletsRepo, required CacheManager cacheManager, required CashuKeyDerivation cashuKeyDerivation, + CashuMintRecommendations? mintRecommendations, CashuUserSeedphrase? cashuUserSeedphrase, }) : _cashuRepo = cashuRepo, _walletsRepo = walletsRepo, _cacheManager = cacheManager, - _cashuKeyDerivation = cashuKeyDerivation { + _cashuKeyDerivation = cashuKeyDerivation, + _mintRecommendations = mintRecommendations { _cashuKeysets = CashuKeysets( cashuRepo: _cashuRepo, cacheManager: _cacheManager, @@ -78,6 +83,13 @@ class Cashu { } } + /// Cashu mint discovery and community reviews. + CashuMintRecommendations get mintRecommendations => + _mintRecommendations ?? + (throw StateError( + 'Cashu NIP-87 requires an NDK Requests instance', + )); + /// mints this usecase has interacted with \ ///? does not mark trusted mints! final Set _knownMints = {}; @@ -500,6 +512,45 @@ class Cashu { return _cashuRepo.getMintInfo(mintUrl: mintUrl); } + /// Gets mint metadata from cache, falling back to NUT-06 `/v1/info`. + Future getMintInfo({ + required String mintUrl, + bool forceRefresh = false, + }) async { + if (!forceRefresh) { + final cached = await _cacheManager.getMintInfos(mintUrls: [mintUrl]); + if (cached != null && cached.isNotEmpty) return cached.first; + } + final info = await _cashuRepo.getMintInfo(mintUrl: mintUrl); + await _cacheManager.saveMintInfo(mintInfo: info); + return info; + } + + /// Discovers ranked Cashu mint recommendations from community events. + /// Mint metadata is intentionally left unloaded for lazy list rendering. + Future> discoverMintRecommendations({ + int? limit, + bool forceRefresh = false, + }) async { + final ranked = await mintRecommendations.discoverMints( + forceRefresh: forceRefresh, + ); + return limit == null ? ranked : ranked.take(limit).toList(); + } + + /// Lazily enriches one recommendation with cached NUT-06 mint metadata. + Future enrichMintRecommendation( + CashuMintRecommendation recommendation, { + Duration timeout = const Duration(seconds: 6), + bool forceRefresh = false, + }) async { + final info = await getMintInfo( + mintUrl: recommendation.url, + forceRefresh: forceRefresh, + ).timeout(timeout); + return recommendation.copyWith(mintInfo: info); + } + /// checks if the mint can be fetched \ /// and adds it to known mints \ /// [mintUrl] is the URL of the mint \ diff --git a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_mint_recommendations.dart b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_mint_recommendations.dart new file mode 100644 index 000000000..2a9f63f35 --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_mint_recommendations.dart @@ -0,0 +1,181 @@ +import '../../entities/cashu/cashu_mint_recommendation.dart'; +import '../../entities/filter.dart'; +import '../../entities/nip_01_event.dart'; +import '../requests/requests.dart'; + +/// Discovers and ranks Cashu mints using NIP-87 announcements and reviews. +class CashuMintRecommendations { + static const int mintAnnouncementKind = 38172; + static const int mintReviewKind = 38000; + static const Set defaultRelays = { + 'wss://relay.cashumints.space', + 'wss://nos.lol', + 'wss://relay.azzamo.net', + 'wss://relay.snort.social', + }; + + final Requests _requests; + final Duration cacheDuration; + List? _cache; + DateTime? _cachedAt; + Future>? _inFlight; + + CashuMintRecommendations({ + required Requests requests, + this.cacheDuration = const Duration(minutes: 15), + }) : _requests = requests; + + /// Returns recommendations ordered by review count, then average rating. + /// + /// Network-delivered events also use NDK's configured persistent event cache. + Future> discoverMints({ + Set relays = defaultRelays, + Duration timeout = const Duration(seconds: 10), + bool forceRefresh = false, + }) async { + final cached = _cache; + final cachedAt = _cachedAt; + if (!forceRefresh && + cached != null && + cachedAt != null && + DateTime.now().difference(cachedAt) < cacheDuration) { + return cached; + } + final inFlight = _inFlight; + if (!forceRefresh && inFlight != null) return inFlight; + + final request = _load(relays: relays, timeout: timeout); + _inFlight = request; + try { + final result = await request; + _cache = result; + _cachedAt = DateTime.now(); + return result; + } finally { + if (identical(_inFlight, request)) _inFlight = null; + } + } + + Future> _load({ + required Set relays, + required Duration timeout, + }) async { + final responses = await Future.wait([ + _requests + .query( + filter: Filter(kinds: const [mintAnnouncementKind], limit: 5000), + explicitRelays: relays, + timeout: timeout, + ) + .stream + .toList(), + _requests + .query( + filter: Filter( + kinds: const [mintReviewKind], + tags: const { + '#k': ['$mintAnnouncementKind'], + }, + limit: 5000, + ), + explicitRelays: relays, + timeout: timeout, + ) + .stream + .toList(), + ]); + return fromEvents( + announcements: responses.first, + reviews: responses.last, + ); + } + + /// Parses fetched events. Public for custom transports and deterministic tests. + static List fromEvents({ + required Iterable announcements, + required Iterable reviews, + }) { + final urls = {}; + for (final event in announcements) { + if (event.kind != mintAnnouncementKind) continue; + urls.addAll(_mintUrls(event)); + } + + final latestByReviewerAndUrl = {}; + for (final event in reviews) { + if (event.kind != mintReviewKind || + _firstTag(event, 'k') != '$mintAnnouncementKind') { + continue; + } + final parsed = _parseReview(event.content); + for (final url in _mintUrls(event)) { + urls.add(url); + final key = '$url|${event.pubKey}'; + final existing = latestByReviewerAndUrl[key]; + if (existing == null || event.createdAt > existing.createdAt) { + latestByReviewerAndUrl[key] = CashuMintReview( + reviewerPubkey: event.pubKey, + createdAt: event.createdAt, + rating: parsed.rating, + comment: parsed.comment, + ); + } + } + } + + final ranked = urls.map((url) { + final mintReviews = latestByReviewerAndUrl.entries + .where((entry) => entry.key.startsWith('$url|')) + .map((entry) => entry.value) + .toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + final ratings = + mintReviews.map((review) => review.rating).whereType().toList(); + return CashuMintRecommendation( + url: url, + averageRating: ratings.isEmpty + ? null + : ratings.reduce((a, b) => a + b) / ratings.length, + reviewsCount: mintReviews.length, + reviews: mintReviews, + ); + }).toList(); + + ranked.sort((a, b) { + final byCount = b.reviewsCount.compareTo(a.reviewsCount); + if (byCount != 0) return byCount; + return (b.averageRating ?? 0).compareTo(a.averageRating ?? 0); + }); + return ranked; + } + + static Iterable _mintUrls(Nip01Event event) sync* { + for (final tag in event.tags) { + if (tag.length < 2 || tag.first != 'u') continue; + final uri = Uri.tryParse(tag[1].trim()); + if (uri?.scheme == 'https' && uri!.host.isNotEmpty) { + yield uri.toString().replaceAll(RegExp(r'/+$'), ''); + } + } + } + + static String? _firstTag(Nip01Event event, String name) { + for (final tag in event.tags) { + if (tag.length >= 2 && tag.first == name) return tag[1]; + } + return null; + } + + static ({int? rating, String comment}) _parseReview(String content) { + final match = RegExp( + r'\s*\[(\d)\s*/\s*5\]\s*(.*)$', + dotAll: true, + ).firstMatch(content); + if (match == null) return (rating: null, comment: content.trim()); + final parsed = int.tryParse(match.group(1)!); + return ( + rating: parsed != null && parsed >= 1 && parsed <= 5 ? parsed : null, + comment: match.group(2)!.trim(), + ); + } +} diff --git a/packages/ndk/lib/entities.dart b/packages/ndk/lib/entities.dart index 3f94f0ae2..156125907 100644 --- a/packages/ndk/lib/entities.dart +++ b/packages/ndk/lib/entities.dart @@ -49,6 +49,7 @@ export 'domain_layer/entities/nip_85.dart'; export 'domain_layer/entities/cashu/cashu_keyset.dart'; export 'domain_layer/entities/cashu/cashu_proof.dart'; export 'domain_layer/entities/cashu/cashu_mint_info.dart'; +export 'domain_layer/entities/cashu/cashu_mint_recommendation.dart'; export 'domain_layer/entities/cashu/cashu_token.dart'; export 'domain_layer/entities/cashu/cashu_user_seedphrase.dart'; export 'domain_layer/entities/cashu/cashu_blinded_message.dart'; diff --git a/packages/ndk/lib/ndk.dart b/packages/ndk/lib/ndk.dart index 45361bb59..f02f93a96 100644 --- a/packages/ndk/lib/ndk.dart +++ b/packages/ndk/lib/ndk.dart @@ -112,6 +112,8 @@ export 'domain_layer/usecases/decrypted_event_payloads/decrypted_event_payloads. export 'domain_layer/usecases/cache_eviction/cache_eviction_scheduler.dart'; export 'domain_layer/usecases/dms/dms.dart'; export 'domain_layer/usecases/cashu/cashu.dart'; +export 'domain_layer/usecases/cashu/cashu_mint_recommendations.dart'; +export 'domain_layer/entities/cashu/cashu_mint_recommendation.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'; diff --git a/packages/ndk/lib/presentation_layer/init.dart b/packages/ndk/lib/presentation_layer/init.dart index e75819dca..e76aecc00 100644 --- a/packages/ndk/lib/presentation_layer/init.dart +++ b/packages/ndk/lib/presentation_layer/init.dart @@ -33,6 +33,7 @@ import '../domain_layer/usecases/bunkers/bunkers.dart'; import '../domain_layer/usecases/cache_eviction/cache_eviction_scheduler.dart'; import '../domain_layer/usecases/cache_read/cache_read.dart'; import '../domain_layer/usecases/cashu/cashu.dart'; +import '../domain_layer/usecases/cashu/cashu_mint_recommendations.dart'; import '../domain_layer/usecases/connectivity/connectivity.dart'; import '../domain_layer/usecases/decrypted_event_payloads/decrypted_event_payloads.dart'; import '../domain_layer/usecases/engines/network_engine.dart'; @@ -254,6 +255,7 @@ class Initialization { cacheManager: _ndkConfig.cache, cashuUserSeedphrase: _ndkConfig.cashuUserSeedphrase, cashuKeyDerivation: DartCashuKeyDerivation(), + mintRecommendations: CashuMintRecommendations(requests: requests), ); // Create wallet providers diff --git a/packages/ndk/test/cashu/cashu_mint_recommendations_test.dart b/packages/ndk/test/cashu/cashu_mint_recommendations_test.dart new file mode 100644 index 000000000..d69875872 --- /dev/null +++ b/packages/ndk/test/cashu/cashu_mint_recommendations_test.dart @@ -0,0 +1,92 @@ +import 'package:ndk/ndk.dart'; +import 'package:test/test.dart'; + +void main() { + group('CashuMintRecommendations.fromEvents', () { + test('keeps latest review per author and ranks recommendations', () { + const mintA = 'https://mint-a.example'; + const mintB = 'https://mint-b.example'; + final recommendations = CashuMintRecommendations.fromEvents( + announcements: [ + _event(kind: 38172, pubkey: 'a', tags: const [ + ['u', mintA] + ]), + _event(kind: 38172, pubkey: 'b', tags: const [ + ['u', mintB] + ]), + ], + reviews: [ + _review(mintA, pubkey: 'one', createdAt: 10, content: '[2/5] old'), + _review(mintA, pubkey: 'one', createdAt: 20, content: '[5/5] great'), + _review(mintA, pubkey: 'two', createdAt: 15, content: '[3/5] okay'), + _review(mintB, pubkey: 'three', createdAt: 30, content: '[5/5] good'), + ], + ); + + expect(recommendations.map((item) => item.url), [mintA, mintB]); + expect(recommendations.first.reviewsCount, 2); + expect(recommendations.first.averageRating, 4); + expect(recommendations.first.reviews.first.comment, 'great'); + }); + + test('rejects non-HTTPS URLs and keeps unrated comments', () { + final recommendations = CashuMintRecommendations.fromEvents( + announcements: const [], + reviews: [ + _review( + 'http://insecure.example', + pubkey: 'one', + createdAt: 10, + content: '[5/5] ignored', + ), + _review( + 'https://mint.example/', + pubkey: 'two', + createdAt: 20, + content: 'Useful comment without rating', + ), + ], + ); + + expect(recommendations, hasLength(1)); + expect(recommendations.single.url, 'https://mint.example'); + expect(recommendations.single.averageRating, isNull); + expect(recommendations.single.reviews.single.comment, + 'Useful comment without rating'); + }); + }); +} + +Nip01Event _review( + String mintUrl, { + required String pubkey, + required int createdAt, + required String content, +}) { + return _event( + kind: 38000, + pubkey: pubkey, + createdAt: createdAt, + tags: [ + const ['k', '38172'], + ['u', mintUrl], + ], + content: content, + ); +} + +Nip01Event _event({ + required int kind, + required String pubkey, + required List> tags, + String content = '', + int createdAt = 1, +}) { + return Nip01Event( + pubKey: pubkey.padRight(64, '0'), + kind: kind, + tags: tags, + content: content, + createdAt: createdAt, + ); +} diff --git a/packages/ndk_flutter/assets/images/albyhub.svg b/packages/ndk_flutter/assets/images/albyhub.svg new file mode 100644 index 000000000..06e23c483 --- /dev/null +++ b/packages/ndk_flutter/assets/images/albyhub.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/ndk_flutter/assets/images/coinos.svg b/packages/ndk_flutter/assets/images/coinos.svg new file mode 100644 index 000000000..891b0eaf3 --- /dev/null +++ b/packages/ndk_flutter/assets/images/coinos.svg @@ -0,0 +1 @@ + diff --git a/packages/ndk_flutter/lib/l10n/app_de.arb b/packages/ndk_flutter/lib/l10n/app_de.arb index db7105196..63f3e9b48 100644 --- a/packages/ndk_flutter/lib/l10n/app_de.arb +++ b/packages/ndk_flutter/lib/l10n/app_de.arb @@ -1,4 +1,79 @@ { + "saveBackupToFile": "Backup in Datei speichern", + "backupSavedToFile": "Backup in Datei gespeichert", + "restoreFromFile": "Aus Datei wiederherstellen", + "backupFileReadFailed": "Die ausgewählte Backup-Datei konnte nicht gelesen werden.", + "addBolt12WalletTitle": "BOLT12-Wallet hinzufügen", + "bolt12Input": "BOLT12-Zahlungsziel", + "bolt12InputHint": "lno1…, bitcoin:?lno=… oder user@domain.com", + "bolt12Wallet": "BOLT12-Wallet", + "bolt12WalletAdded": "BOLT12-Wallet erfolgreich hinzugefügt!", + "bolt12WalletTypeTitle": "BOLT12-Angebot", + "enterBolt12Input": "Gib ein lno-Angebot, einen bitcoin:?lno=…-URI oder eine BIP353-Adresse ein oder scanne sie.", + "pleaseEnterBolt12Input": "Bitte gib ein BOLT12-Angebot oder eine BIP353-Adresse ein.", + "scanBolt12QrCodeTitle": "BOLT12-QR-Code scannen", + "walletNameOptional": "Wallet-Name (optional)", + "fetchingWalletConnectionInfo": "Wallet-Verbindungsdaten werden abgerufen…", + "addWalletDescription": "Scanne einen unterstützten Wallet-QR-Code, füge Verbindungsdaten ein oder verbinde dich über eine Wallet-App.", + "scanWalletQrCode": "Wallet-QR-Code scannen", + "connectWithWallet": "Mit einer Wallet verbinden", + "chooseWalletApp": "Wallet-App auswählen", + "oneClickConnect": "Mit 1 Klick verbinden", + "chooseWallet": "Wallet auswählen", + "albyWalletOption": "Alby", + "albyCloudOption": "Alby Cloud", + "coinosWalletOption": "Coinos", + "manualNwcConnection": "Manuelle NWC-Verbindung", + "walletConnectionFinishIn": "Verbindung in {walletName} abschließen", + "walletConnectionConnecting": "{walletName} wird verbunden…", + "walletConnectionConnected": "{walletName} verbunden", + "walletConnectionFailed": "{walletName} konnte nicht verbunden werden", + "retry": "Erneut versuchen", + "chooseAnotherWallet": "Andere Wallet auswählen", + "chooseWalletAppDescription": "Eine NWC-Verbindung in einer installierten Wallet genehmigen", + "walletInput": "Wallet-Adresse oder Verbindung", + "walletInputHint": "NWC, Lightning-/BIP353-Adresse, BOLT12-/BIP321-Angebot oder HTTPS-URL eines Cashu-Mints", + "unsupportedWalletInput": "Dies ist keine unterstützte Wallet-Adresse oder Verbindung.", + "detected": "Erkannt", + "lightningAddressInputType": "Lightning- oder BIP353-Adresse", + "manualWalletSetup": "Manuell einrichten", + "chooseCashuMint": "Cashu-Mint auswählen", + "cashuMintRatingsNotice": "Community-Bewertungen stammen aus signierten Nostr-Rezensionen. Eine hohe Bewertung garantiert nicht, dass ein Mint sicher ist.", + "cashuMintDiscoveryFailed": "Mint-Vorschläge konnten nicht geladen werden.", + "noCashuMintSuggestions": "Keine verfügbaren Mint-Vorschläge gefunden.", + "noRatingsYet": "Noch keine Bewertungen", + "cashuMintRating": "★ {rating} · {count} Bewertungen", + "enterMintUrlManually": "Mint-URL manuell eingeben", + "bip353WalletTypeTitle": "BIP353", + "lnurlProtocol": "LNURL", + "confirm": "Bestätigen", + "reviewWallet": "Wallet prüfen", + "confirmWalletTitle": "Wallet bestätigen", + "confirmWalletDescription": "Prüfe diese Angaben, bevor du die Wallet hinzufügst.", + "walletDetailType": "Wallet-Typ", + "walletDetailAddress": "Adresse", + "walletDetailDomain": "Domain", + "walletDetailUrl": "URL", + "walletDetailPublicKey": "Öffentlicher Schlüssel", + "walletDetailRelay": "Relay", + "walletDetailRelays": "Relays", + "walletDetailSecret": "Verbindungsgeheimnis", + "walletSecretHidden": "Vorhanden und aus Sicherheitsgründen ausgeblendet", + "walletDetailDescription": "Beschreibung", + "walletDetailDetails": "Details", + "walletDetailIssuer": "Aussteller", + "walletDetailAmount": "Betrag", + "walletDetailCurrency": "Währung", + "walletDetailExpiry": "Läuft ab", + "walletDetailNodeId": "Node-ID", + "walletDetailOffer": "BOLT12-Angebot", + "walletDetailVersion": "Version", + "walletDetailUnits": "Unterstützte Einheiten", + "walletDetailContact": "Kontakt", + "walletDetailTerms": "Nutzungsbedingungen", + "walletDetailMessage": "Nachricht", + "walletDetailCommunityRating": "Community-Bewertung", + "walletDetailCommunityReviews": "Aktuelle Community-Rezensionen", "@@locale": "de", "createAccount": "Konto erstellen", "newHere": "Bist du neu hier?", diff --git a/packages/ndk_flutter/lib/l10n/app_en.arb b/packages/ndk_flutter/lib/l10n/app_en.arb index c28dfbc3f..c71631fbb 100644 --- a/packages/ndk_flutter/lib/l10n/app_en.arb +++ b/packages/ndk_flutter/lib/l10n/app_en.arb @@ -1,4 +1,9 @@ { + "saveBackupToFile": "Save backup to file", + "backupSavedToFile": "Backup saved to file", + "restoreFromFile": "Restore from file", + "backupFileReadFailed": "Could not read the selected backup file.", + "fetchingWalletConnectionInfo": "Fetching wallet connection info…", "@@locale": "en", "createAccount": "Create your account", "@createAccount": { @@ -1286,6 +1291,78 @@ "@addWalletTitle": { "description": "Title for add wallet dialog" }, + "addWalletDescription": "Scan any supported wallet QR code, paste its details, or connect through a wallet app.", + "@addWalletDescription": { + "description": "Description for the unified add wallet flow" + }, + "scanWalletQrCode": "Scan wallet QR code", + "@scanWalletQrCode": { + "description": "Button for opening the universal wallet QR scanner" + }, + "connectWithWallet": "Connect with a wallet", + "@connectWithWallet": { + "description": "Heading for wallet-assisted NWC connection options" + }, + "chooseWalletApp": "Choose wallet app", + "oneClickConnect": "1-click connect", + "chooseWallet": "Choose wallet", + "albyWalletOption": "Alby", + "albyCloudOption": "Alby Cloud", + "coinosWalletOption": "Coinos", + "manualNwcConnection": "Manual NWC connection", + "walletConnectionFinishIn": "Finish connection in {walletName}", + "@walletConnectionFinishIn": { + "description": "Prompt shown while an external wallet is authorizing", + "placeholders": {"walletName": {"type": "String"}} + }, + "walletConnectionConnecting": "Connecting {walletName}…", + "@walletConnectionConnecting": { + "description": "Progress shown while adding an externally authorized wallet", + "placeholders": {"walletName": {"type": "String"}} + }, + "walletConnectionConnected": "{walletName} connected", + "@walletConnectionConnected": { + "description": "Animated success message after adding a wallet", + "placeholders": {"walletName": {"type": "String"}} + }, + "walletConnectionFailed": "Could not connect {walletName}", + "@walletConnectionFailed": { + "description": "Heading shown when an external wallet connection fails", + "placeholders": {"walletName": {"type": "String"}} + }, + "retry": "Retry", + "chooseAnotherWallet": "Choose another wallet", + "@chooseWalletApp": { + "description": "Button for opening the standard NWC wallet chooser" + }, + "chooseWalletAppDescription": "Approve an NWC connection in an installed wallet", + "@chooseWalletAppDescription": { + "description": "Description for the standard NWC wallet chooser" + }, + "walletInput": "Wallet address or connection", + "@walletInput": { + "description": "Label for unified wallet input" + }, + "walletInputHint": "NWC, Lightning/BIP353 address, BOLT12/BIP321 offer, or HTTPS Cashu mint URL", + "@walletInputHint": { + "description": "Hint listing supported wallet inputs" + }, + "unsupportedWalletInput": "This is not a supported wallet address or connection.", + "@unsupportedWalletInput": { + "description": "Error for an unrecognized unified wallet input" + }, + "detected": "Detected", + "@detected": { + "description": "Label shown before a detected wallet type" + }, + "lightningAddressInputType": "Lightning or BIP353 address", + "@lightningAddressInputType": { + "description": "Detected type label for an ambiguous user at domain address" + }, + "manualWalletSetup": "Set up manually", + "@manualWalletSetup": { + "description": "Button revealing type-specific manual wallet setup" + }, "chooseWalletType": "Choose wallet type", "@chooseWalletType": { "description": "Prompt to choose wallet type" @@ -1307,6 +1384,19 @@ "description": "Subtitle for the LNURL wallet type option" }, "cashuWalletTypeTitle": "Cashu", + "chooseCashuMint": "Choose Cashu mint", + "cashuMintRatingsNotice": "Community ratings come from signed Nostr reviews. A high rating does not guarantee that a mint is safe.", + "cashuMintDiscoveryFailed": "Could not load mint suggestions.", + "noCashuMintSuggestions": "No available mint suggestions found.", + "noRatingsYet": "No ratings yet", + "cashuMintRating": "★ {rating} · {count} reviews", + "@cashuMintRating": { + "placeholders": { + "rating": {"type": "String"}, + "count": {"type": "int"} + } + }, + "enterMintUrlManually": "Enter mint URL manually", "@cashuWalletTypeTitle": { "description": "Title for the Cashu wallet type option" }, @@ -1551,6 +1641,8 @@ } }, "bolt12WalletTypeTitle": "BOLT12 Offer", + "bip353WalletTypeTitle": "BIP353", + "lnurlProtocol": "LNURL", "bolt12WalletTypeSubtitle": "Receive-only wallet using a reusable offer", "addBolt12WalletTitle": "Add BOLT12 Wallet", "enterBolt12Input": "Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.", @@ -1562,5 +1654,33 @@ "pleaseEnterBolt12Input": "Please enter a BOLT12 offer or BIP353 address.", "bolt12WalletAdded": "BOLT12 wallet added successfully!", "bolt12OfferTitle": "Receive with BOLT12", - "bolt12OfferInstructions": "Share this reusable offer to receive a Lightning payment." + "bolt12OfferInstructions": "Share this reusable offer to receive a Lightning payment.", + "confirm": "Confirm", + "reviewWallet": "Review wallet", + "confirmWalletTitle": "Confirm wallet", + "confirmWalletDescription": "Review these details before adding this wallet.", + "walletDetailType": "Wallet type", + "walletDetailAddress": "Address", + "walletDetailDomain": "Domain", + "walletDetailUrl": "URL", + "walletDetailPublicKey": "Public key", + "walletDetailRelay": "Relay", + "walletDetailRelays": "Relays", + "walletDetailSecret": "Connection secret", + "walletSecretHidden": "Present and hidden for security", + "walletDetailDescription": "Description", + "walletDetailDetails": "Details", + "walletDetailIssuer": "Issuer", + "walletDetailAmount": "Amount", + "walletDetailCurrency": "Currency", + "walletDetailExpiry": "Expires", + "walletDetailNodeId": "Node ID", + "walletDetailOffer": "BOLT12 offer", + "walletDetailVersion": "Version", + "walletDetailUnits": "Supported units", + "walletDetailContact": "Contact", + "walletDetailTerms": "Terms of service", + "walletDetailMessage": "Message", + "walletDetailCommunityRating": "Community rating", + "walletDetailCommunityReviews": "Recent community reviews" } diff --git a/packages/ndk_flutter/lib/l10n/app_es.arb b/packages/ndk_flutter/lib/l10n/app_es.arb index 3d3144e03..d112a184a 100644 --- a/packages/ndk_flutter/lib/l10n/app_es.arb +++ b/packages/ndk_flutter/lib/l10n/app_es.arb @@ -1,4 +1,79 @@ { + "saveBackupToFile": "Guardar copia en un archivo", + "backupSavedToFile": "Copia guardada en un archivo", + "restoreFromFile": "Restaurar desde un archivo", + "backupFileReadFailed": "No se pudo leer el archivo de copia seleccionado.", + "addBolt12WalletTitle": "Añadir cartera BOLT12", + "bolt12Input": "Destino de pago BOLT12", + "bolt12InputHint": "lno1…, bitcoin:?lno=… o usuario@dominio.com", + "bolt12Wallet": "Cartera BOLT12", + "bolt12WalletAdded": "¡Cartera BOLT12 añadida correctamente!", + "bolt12WalletTypeTitle": "Oferta BOLT12", + "enterBolt12Input": "Introduce o escanea una oferta lno, un URI bitcoin:?lno=… o una dirección BIP353.", + "pleaseEnterBolt12Input": "Introduce una oferta BOLT12 o una dirección BIP353.", + "scanBolt12QrCodeTitle": "Escanear código QR BOLT12", + "walletNameOptional": "Nombre de la cartera (opcional)", + "fetchingWalletConnectionInfo": "Obteniendo información de conexión de la cartera…", + "addWalletDescription": "Escanea un código QR de cartera compatible, pega sus datos o conéctate mediante una aplicación de cartera.", + "scanWalletQrCode": "Escanear QR de cartera", + "connectWithWallet": "Conectar con una cartera", + "chooseWalletApp": "Elegir aplicación de cartera", + "oneClickConnect": "Conectar con 1 clic", + "chooseWallet": "Elegir cartera", + "albyWalletOption": "Alby", + "albyCloudOption": "Alby Cloud", + "coinosWalletOption": "Coinos", + "manualNwcConnection": "Conexión NWC manual", + "walletConnectionFinishIn": "Finaliza la conexión en {walletName}", + "walletConnectionConnecting": "Conectando {walletName}…", + "walletConnectionConnected": "{walletName} conectada", + "walletConnectionFailed": "No se pudo conectar {walletName}", + "retry": "Reintentar", + "chooseAnotherWallet": "Elegir otra cartera", + "chooseWalletAppDescription": "Aprueba una conexión NWC en una cartera instalada", + "walletInput": "Dirección o conexión de cartera", + "walletInputHint": "NWC, dirección Lightning/BIP353, oferta BOLT12/BIP321 o URL HTTPS de un mint Cashu", + "unsupportedWalletInput": "Esta dirección o conexión de cartera no es compatible.", + "detected": "Detectado", + "lightningAddressInputType": "Dirección Lightning o BIP353", + "manualWalletSetup": "Configurar manualmente", + "chooseCashuMint": "Elegir mint Cashu", + "cashuMintRatingsNotice": "Las valoraciones de la comunidad proceden de reseñas Nostr firmadas. Una valoración alta no garantiza que un mint sea seguro.", + "cashuMintDiscoveryFailed": "No se pudieron cargar las sugerencias de mints.", + "noCashuMintSuggestions": "No se encontraron sugerencias de mints disponibles.", + "noRatingsYet": "Aún sin valoraciones", + "cashuMintRating": "★ {rating} · {count} reseñas", + "enterMintUrlManually": "Introducir URL del mint manualmente", + "bip353WalletTypeTitle": "BIP353", + "lnurlProtocol": "LNURL", + "confirm": "Confirmar", + "reviewWallet": "Revisar cartera", + "confirmWalletTitle": "Confirmar cartera", + "confirmWalletDescription": "Revisa estos datos antes de añadir la cartera.", + "walletDetailType": "Tipo de cartera", + "walletDetailAddress": "Dirección", + "walletDetailDomain": "Dominio", + "walletDetailUrl": "URL", + "walletDetailPublicKey": "Clave pública", + "walletDetailRelay": "Relay", + "walletDetailRelays": "Relays", + "walletDetailSecret": "Secreto de conexión", + "walletSecretHidden": "Presente y oculto por seguridad", + "walletDetailDescription": "Descripción", + "walletDetailDetails": "Detalles", + "walletDetailIssuer": "Emisor", + "walletDetailAmount": "Importe", + "walletDetailCurrency": "Moneda", + "walletDetailExpiry": "Caduca", + "walletDetailNodeId": "ID del nodo", + "walletDetailOffer": "Oferta BOLT12", + "walletDetailVersion": "Versión", + "walletDetailUnits": "Unidades compatibles", + "walletDetailContact": "Contacto", + "walletDetailTerms": "Términos del servicio", + "walletDetailMessage": "Mensaje", + "walletDetailCommunityRating": "Valoración de la comunidad", + "walletDetailCommunityReviews": "Reseñas recientes de la comunidad", "@@locale": "es", "createAccount": "Crear tu cuenta", "newHere": "¿Eres nuevo aquí?", diff --git a/packages/ndk_flutter/lib/l10n/app_fi.arb b/packages/ndk_flutter/lib/l10n/app_fi.arb index 67d30f16a..41e0cfc5d 100644 --- a/packages/ndk_flutter/lib/l10n/app_fi.arb +++ b/packages/ndk_flutter/lib/l10n/app_fi.arb @@ -1,4 +1,79 @@ { + "saveBackupToFile": "Tallenna varmuuskopio tiedostoon", + "backupSavedToFile": "Varmuuskopio tallennettu tiedostoon", + "restoreFromFile": "Palauta tiedostosta", + "backupFileReadFailed": "Valittua varmuuskopiotiedostoa ei voitu lukea.", + "addBolt12WalletTitle": "Lisää BOLT12-lompakko", + "bolt12Input": "BOLT12-maksukohde", + "bolt12InputHint": "lno1…, bitcoin:?lno=… tai käyttäjä@verkkotunnus.com", + "bolt12Wallet": "BOLT12-lompakko", + "bolt12WalletAdded": "BOLT12-lompakko lisätty!", + "bolt12WalletTypeTitle": "BOLT12-tarjous", + "enterBolt12Input": "Syötä tai skannaa lno-tarjous, bitcoin:?lno=…-URI tai BIP353-osoite.", + "pleaseEnterBolt12Input": "Syötä BOLT12-tarjous tai BIP353-osoite.", + "scanBolt12QrCodeTitle": "Skannaa BOLT12-QR-koodi", + "walletNameOptional": "Lompakon nimi (valinnainen)", + "fetchingWalletConnectionInfo": "Haetaan lompakon yhteystietoja…", + "addWalletDescription": "Skannaa tuetun lompakon QR-koodi, liitä sen tiedot tai yhdistä lompakkosovelluksella.", + "scanWalletQrCode": "Skannaa lompakon QR-koodi", + "connectWithWallet": "Yhdistä lompakkoon", + "chooseWalletApp": "Valitse lompakkosovellus", + "oneClickConnect": "Yhdistä yhdellä napsautuksella", + "chooseWallet": "Valitse lompakko", + "albyWalletOption": "Alby", + "albyCloudOption": "Alby Cloud", + "coinosWalletOption": "Coinos", + "manualNwcConnection": "Manuaalinen NWC-yhteys", + "walletConnectionFinishIn": "Viimeistele yhteys sovelluksessa {walletName}", + "walletConnectionConnecting": "Yhdistetään lompakkoon {walletName}…", + "walletConnectionConnected": "{walletName} yhdistetty", + "walletConnectionFailed": "Lompakkoon {walletName} ei voitu yhdistää", + "retry": "Yritä uudelleen", + "chooseAnotherWallet": "Valitse toinen lompakko", + "chooseWalletAppDescription": "Hyväksy NWC-yhteys asennetussa lompakossa", + "walletInput": "Lompakon osoite tai yhteys", + "walletInputHint": "NWC, Lightning-/BIP353-osoite, BOLT12-/BIP321-tarjous tai Cashu-mintin HTTPS-URL", + "unsupportedWalletInput": "Tätä lompakon osoitetta tai yhteyttä ei tueta.", + "detected": "Havaittu", + "lightningAddressInputType": "Lightning- tai BIP353-osoite", + "manualWalletSetup": "Määritä manuaalisesti", + "chooseCashuMint": "Valitse Cashu-mintti", + "cashuMintRatingsNotice": "Yhteisöarviot ovat allekirjoitettuja Nostr-arvosteluja. Korkea arvio ei takaa mintin turvallisuutta.", + "cashuMintDiscoveryFailed": "Minttiehdotuksia ei voitu ladata.", + "noCashuMintSuggestions": "Saatavilla olevia minttiehdotuksia ei löytynyt.", + "noRatingsYet": "Ei vielä arvioita", + "cashuMintRating": "★ {rating} · {count} arvostelua", + "enterMintUrlManually": "Syötä mintin URL manuaalisesti", + "bip353WalletTypeTitle": "BIP353", + "lnurlProtocol": "LNURL", + "confirm": "Vahvista", + "reviewWallet": "Tarkista lompakko", + "confirmWalletTitle": "Vahvista lompakko", + "confirmWalletDescription": "Tarkista nämä tiedot ennen lompakon lisäämistä.", + "walletDetailType": "Lompakon tyyppi", + "walletDetailAddress": "Osoite", + "walletDetailDomain": "Verkkotunnus", + "walletDetailUrl": "URL", + "walletDetailPublicKey": "Julkinen avain", + "walletDetailRelay": "Rele", + "walletDetailRelays": "Releet", + "walletDetailSecret": "Yhteyssalaisuus", + "walletSecretHidden": "Olemassa ja piilotettu turvallisuuden vuoksi", + "walletDetailDescription": "Kuvaus", + "walletDetailDetails": "Tiedot", + "walletDetailIssuer": "Myöntäjä", + "walletDetailAmount": "Summa", + "walletDetailCurrency": "Valuutta", + "walletDetailExpiry": "Vanhenee", + "walletDetailNodeId": "Solmun tunnus", + "walletDetailOffer": "BOLT12-tarjous", + "walletDetailVersion": "Versio", + "walletDetailUnits": "Tuetut yksiköt", + "walletDetailContact": "Yhteystieto", + "walletDetailTerms": "Käyttöehdot", + "walletDetailMessage": "Viesti", + "walletDetailCommunityRating": "Yhteisön arvio", + "walletDetailCommunityReviews": "Viimeisimmät yhteisöarvostelut", "@@locale": "fi", "createAccount": "Luo tili", "newHere": "Oletko uusi täällä?", diff --git a/packages/ndk_flutter/lib/l10n/app_fr.arb b/packages/ndk_flutter/lib/l10n/app_fr.arb index 20e136525..f397cb378 100644 --- a/packages/ndk_flutter/lib/l10n/app_fr.arb +++ b/packages/ndk_flutter/lib/l10n/app_fr.arb @@ -1,4 +1,79 @@ { + "saveBackupToFile": "Enregistrer la sauvegarde dans un fichier", + "backupSavedToFile": "Sauvegarde enregistrée dans un fichier", + "restoreFromFile": "Restaurer depuis un fichier", + "backupFileReadFailed": "Impossible de lire le fichier de sauvegarde sélectionné.", + "addBolt12WalletTitle": "Ajouter un portefeuille BOLT12", + "bolt12Input": "Cible de paiement BOLT12", + "bolt12InputHint": "lno1…, bitcoin:?lno=… ou utilisateur@domaine.com", + "bolt12Wallet": "Portefeuille BOLT12", + "bolt12WalletAdded": "Portefeuille BOLT12 ajouté !", + "bolt12WalletTypeTitle": "Offre BOLT12", + "enterBolt12Input": "Saisissez ou scannez une offre lno, un URI bitcoin:?lno=… ou une adresse BIP353.", + "pleaseEnterBolt12Input": "Saisissez une offre BOLT12 ou une adresse BIP353.", + "scanBolt12QrCodeTitle": "Scanner le QR code BOLT12", + "walletNameOptional": "Nom du portefeuille (facultatif)", + "fetchingWalletConnectionInfo": "Récupération des informations de connexion du portefeuille…", + "addWalletDescription": "Scannez un QR code de portefeuille compatible, collez ses informations ou connectez-vous via une application de portefeuille.", + "scanWalletQrCode": "Scanner le QR code du portefeuille", + "connectWithWallet": "Connecter un portefeuille", + "chooseWalletApp": "Choisir une application de portefeuille", + "oneClickConnect": "Connexion en 1 clic", + "chooseWallet": "Choisir un portefeuille", + "albyWalletOption": "Alby", + "albyCloudOption": "Alby Cloud", + "coinosWalletOption": "Coinos", + "manualNwcConnection": "Connexion NWC manuelle", + "walletConnectionFinishIn": "Terminez la connexion dans {walletName}", + "walletConnectionConnecting": "Connexion à {walletName}…", + "walletConnectionConnected": "{walletName} connecté", + "walletConnectionFailed": "Impossible de connecter {walletName}", + "retry": "Réessayer", + "chooseAnotherWallet": "Choisir un autre portefeuille", + "chooseWalletAppDescription": "Approuvez une connexion NWC dans un portefeuille installé", + "walletInput": "Adresse ou connexion du portefeuille", + "walletInputHint": "NWC, adresse Lightning/BIP353, offre BOLT12/BIP321 ou URL HTTPS d'un mint Cashu", + "unsupportedWalletInput": "Cette adresse ou connexion de portefeuille n'est pas prise en charge.", + "detected": "Détecté", + "lightningAddressInputType": "Adresse Lightning ou BIP353", + "manualWalletSetup": "Configurer manuellement", + "chooseCashuMint": "Choisir un mint Cashu", + "cashuMintRatingsNotice": "Les notes de la communauté proviennent d'avis Nostr signés. Une note élevée ne garantit pas la sécurité d'un mint.", + "cashuMintDiscoveryFailed": "Impossible de charger les suggestions de mints.", + "noCashuMintSuggestions": "Aucune suggestion de mint disponible.", + "noRatingsYet": "Aucune note pour le moment", + "cashuMintRating": "★ {rating} · {count} avis", + "enterMintUrlManually": "Saisir l'URL du mint manuellement", + "bip353WalletTypeTitle": "BIP353", + "lnurlProtocol": "LNURL", + "confirm": "Confirmer", + "reviewWallet": "Vérifier le portefeuille", + "confirmWalletTitle": "Confirmer le portefeuille", + "confirmWalletDescription": "Vérifiez ces informations avant d'ajouter ce portefeuille.", + "walletDetailType": "Type de portefeuille", + "walletDetailAddress": "Adresse", + "walletDetailDomain": "Domaine", + "walletDetailUrl": "URL", + "walletDetailPublicKey": "Clé publique", + "walletDetailRelay": "Relais", + "walletDetailRelays": "Relais", + "walletDetailSecret": "Secret de connexion", + "walletSecretHidden": "Présent et masqué pour des raisons de sécurité", + "walletDetailDescription": "Description", + "walletDetailDetails": "Détails", + "walletDetailIssuer": "Émetteur", + "walletDetailAmount": "Montant", + "walletDetailCurrency": "Devise", + "walletDetailExpiry": "Expiration", + "walletDetailNodeId": "ID du nœud", + "walletDetailOffer": "Offre BOLT12", + "walletDetailVersion": "Version", + "walletDetailUnits": "Unités prises en charge", + "walletDetailContact": "Contact", + "walletDetailTerms": "Conditions d'utilisation", + "walletDetailMessage": "Message", + "walletDetailCommunityRating": "Note de la communauté", + "walletDetailCommunityReviews": "Avis récents de la communauté", "@@locale": "fr", "createAccount": "Créer votre compte", "newHere": "Êtes-vous nouveau ici?", diff --git a/packages/ndk_flutter/lib/l10n/app_it.arb b/packages/ndk_flutter/lib/l10n/app_it.arb index 27934d782..197c71117 100644 --- a/packages/ndk_flutter/lib/l10n/app_it.arb +++ b/packages/ndk_flutter/lib/l10n/app_it.arb @@ -1,4 +1,79 @@ { + "saveBackupToFile": "Salva backup su file", + "backupSavedToFile": "Backup salvato su file", + "restoreFromFile": "Ripristina da file", + "backupFileReadFailed": "Impossibile leggere il file di backup selezionato.", + "addBolt12WalletTitle": "Aggiungi portafoglio BOLT12", + "bolt12Input": "Destinazione di pagamento BOLT12", + "bolt12InputHint": "lno1…, bitcoin:?lno=… o utente@dominio.com", + "bolt12Wallet": "Portafoglio BOLT12", + "bolt12WalletAdded": "Portafoglio BOLT12 aggiunto!", + "bolt12WalletTypeTitle": "Offerta BOLT12", + "enterBolt12Input": "Inserisci o scansiona un'offerta lno, un URI bitcoin:?lno=… o un indirizzo BIP353.", + "pleaseEnterBolt12Input": "Inserisci un'offerta BOLT12 o un indirizzo BIP353.", + "scanBolt12QrCodeTitle": "Scansiona codice QR BOLT12", + "walletNameOptional": "Nome del portafoglio (facoltativo)", + "fetchingWalletConnectionInfo": "Recupero delle informazioni di connessione del portafoglio…", + "addWalletDescription": "Scansiona un codice QR di un portafoglio supportato, incolla i dati o connettiti tramite un'app portafoglio.", + "scanWalletQrCode": "Scansiona QR del portafoglio", + "connectWithWallet": "Connetti un portafoglio", + "chooseWalletApp": "Scegli app portafoglio", + "oneClickConnect": "Connessione in 1 clic", + "chooseWallet": "Scegli portafoglio", + "albyWalletOption": "Alby", + "albyCloudOption": "Alby Cloud", + "coinosWalletOption": "Coinos", + "manualNwcConnection": "Connessione NWC manuale", + "walletConnectionFinishIn": "Completa la connessione in {walletName}", + "walletConnectionConnecting": "Connessione a {walletName}…", + "walletConnectionConnected": "{walletName} connesso", + "walletConnectionFailed": "Impossibile connettere {walletName}", + "retry": "Riprova", + "chooseAnotherWallet": "Scegli un altro portafoglio", + "chooseWalletAppDescription": "Approva una connessione NWC in un portafoglio installato", + "walletInput": "Indirizzo o connessione del portafoglio", + "walletInputHint": "NWC, indirizzo Lightning/BIP353, offerta BOLT12/BIP321 o URL HTTPS di un mint Cashu", + "unsupportedWalletInput": "Questo indirizzo o connessione del portafoglio non è supportato.", + "detected": "Rilevato", + "lightningAddressInputType": "Indirizzo Lightning o BIP353", + "manualWalletSetup": "Configura manualmente", + "chooseCashuMint": "Scegli mint Cashu", + "cashuMintRatingsNotice": "Le valutazioni della community provengono da recensioni Nostr firmate. Una valutazione alta non garantisce che un mint sia sicuro.", + "cashuMintDiscoveryFailed": "Impossibile caricare i suggerimenti dei mint.", + "noCashuMintSuggestions": "Nessun suggerimento di mint disponibile.", + "noRatingsYet": "Ancora nessuna valutazione", + "cashuMintRating": "★ {rating} · {count} recensioni", + "enterMintUrlManually": "Inserisci manualmente l'URL del mint", + "bip353WalletTypeTitle": "BIP353", + "lnurlProtocol": "LNURL", + "confirm": "Conferma", + "reviewWallet": "Controlla portafoglio", + "confirmWalletTitle": "Conferma portafoglio", + "confirmWalletDescription": "Controlla questi dati prima di aggiungere il portafoglio.", + "walletDetailType": "Tipo di portafoglio", + "walletDetailAddress": "Indirizzo", + "walletDetailDomain": "Dominio", + "walletDetailUrl": "URL", + "walletDetailPublicKey": "Chiave pubblica", + "walletDetailRelay": "Relay", + "walletDetailRelays": "Relay", + "walletDetailSecret": "Segreto di connessione", + "walletSecretHidden": "Presente e nascosto per sicurezza", + "walletDetailDescription": "Descrizione", + "walletDetailDetails": "Dettagli", + "walletDetailIssuer": "Emittente", + "walletDetailAmount": "Importo", + "walletDetailCurrency": "Valuta", + "walletDetailExpiry": "Scadenza", + "walletDetailNodeId": "ID nodo", + "walletDetailOffer": "Offerta BOLT12", + "walletDetailVersion": "Versione", + "walletDetailUnits": "Unità supportate", + "walletDetailContact": "Contatto", + "walletDetailTerms": "Termini di servizio", + "walletDetailMessage": "Messaggio", + "walletDetailCommunityRating": "Valutazione della community", + "walletDetailCommunityReviews": "Recensioni recenti della community", "@@locale": "it", "createAccount": "Crea il tuo account", "newHere": "Sei nuovo qui?", diff --git a/packages/ndk_flutter/lib/l10n/app_ja.arb b/packages/ndk_flutter/lib/l10n/app_ja.arb index b25669554..fe7554bd3 100644 --- a/packages/ndk_flutter/lib/l10n/app_ja.arb +++ b/packages/ndk_flutter/lib/l10n/app_ja.arb @@ -1,4 +1,79 @@ { + "saveBackupToFile": "バックアップをファイルに保存", + "backupSavedToFile": "バックアップをファイルに保存しました", + "restoreFromFile": "ファイルから復元", + "backupFileReadFailed": "選択したバックアップファイルを読み込めませんでした。", + "addBolt12WalletTitle": "BOLT12ウォレットを追加", + "bolt12Input": "BOLT12支払い先", + "bolt12InputHint": "lno1…、bitcoin:?lno=…、またはuser@domain.com", + "bolt12Wallet": "BOLT12ウォレット", + "bolt12WalletAdded": "BOLT12ウォレットを追加しました!", + "bolt12WalletTypeTitle": "BOLT12オファー", + "enterBolt12Input": "lnoオファー、bitcoin:?lno=… URI、またはBIP353アドレスを入力またはスキャンしてください。", + "pleaseEnterBolt12Input": "BOLT12オファーまたはBIP353アドレスを入力してください。", + "scanBolt12QrCodeTitle": "BOLT12 QRコードをスキャン", + "walletNameOptional": "ウォレット名(任意)", + "fetchingWalletConnectionInfo": "ウォレットの接続情報を取得中…", + "addWalletDescription": "対応するウォレットのQRコードをスキャンするか、接続情報を貼り付けるか、ウォレットアプリから接続します。", + "scanWalletQrCode": "ウォレットのQRコードをスキャン", + "connectWithWallet": "ウォレットに接続", + "chooseWalletApp": "ウォレットアプリを選択", + "oneClickConnect": "1クリック接続", + "chooseWallet": "ウォレットを選択", + "albyWalletOption": "Alby", + "albyCloudOption": "Alby Cloud", + "coinosWalletOption": "Coinos", + "manualNwcConnection": "NWCを手動接続", + "walletConnectionFinishIn": "{walletName}で接続を完了してください", + "walletConnectionConnecting": "{walletName}に接続中…", + "walletConnectionConnected": "{walletName}に接続しました", + "walletConnectionFailed": "{walletName}に接続できませんでした", + "retry": "再試行", + "chooseAnotherWallet": "別のウォレットを選択", + "chooseWalletAppDescription": "インストール済みウォレットでNWC接続を承認します", + "walletInput": "ウォレットアドレスまたは接続情報", + "walletInputHint": "NWC、Lightning/BIP353アドレス、BOLT12/BIP321オファー、またはCashuミントのHTTPS URL", + "unsupportedWalletInput": "対応していないウォレットアドレスまたは接続情報です。", + "detected": "検出済み", + "lightningAddressInputType": "LightningまたはBIP353アドレス", + "manualWalletSetup": "手動で設定", + "chooseCashuMint": "Cashuミントを選択", + "cashuMintRatingsNotice": "コミュニティ評価は署名済みNostrレビューに基づきます。高評価でもミントの安全性は保証されません。", + "cashuMintDiscoveryFailed": "ミント候補を読み込めませんでした。", + "noCashuMintSuggestions": "利用可能なミント候補が見つかりません。", + "noRatingsYet": "まだ評価がありません", + "cashuMintRating": "★ {rating} · {count}件のレビュー", + "enterMintUrlManually": "ミントURLを手動入力", + "bip353WalletTypeTitle": "BIP353", + "lnurlProtocol": "LNURL", + "confirm": "確認", + "reviewWallet": "ウォレットを確認", + "confirmWalletTitle": "ウォレットを確認", + "confirmWalletDescription": "このウォレットを追加する前に詳細を確認してください。", + "walletDetailType": "ウォレットの種類", + "walletDetailAddress": "アドレス", + "walletDetailDomain": "ドメイン", + "walletDetailUrl": "URL", + "walletDetailPublicKey": "公開鍵", + "walletDetailRelay": "リレー", + "walletDetailRelays": "リレー", + "walletDetailSecret": "接続シークレット", + "walletSecretHidden": "存在します(安全のため非表示)", + "walletDetailDescription": "説明", + "walletDetailDetails": "詳細", + "walletDetailIssuer": "発行者", + "walletDetailAmount": "金額", + "walletDetailCurrency": "通貨", + "walletDetailExpiry": "有効期限", + "walletDetailNodeId": "ノードID", + "walletDetailOffer": "BOLT12オファー", + "walletDetailVersion": "バージョン", + "walletDetailUnits": "対応単位", + "walletDetailContact": "連絡先", + "walletDetailTerms": "利用規約", + "walletDetailMessage": "メッセージ", + "walletDetailCommunityRating": "コミュニティ評価", + "walletDetailCommunityReviews": "最近のコミュニティレビュー", "@@locale": "ja", "createAccount": "アカウントを作成", "newHere": "初めてですか?", diff --git a/packages/ndk_flutter/lib/l10n/app_localizations.dart b/packages/ndk_flutter/lib/l10n/app_localizations.dart index 1e5821e32..71d17848e 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations.dart @@ -119,6 +119,36 @@ abstract class AppLocalizations { Locale('zh'), ]; + /// No description provided for @saveBackupToFile. + /// + /// In en, this message translates to: + /// **'Save backup to file'** + String get saveBackupToFile; + + /// No description provided for @backupSavedToFile. + /// + /// In en, this message translates to: + /// **'Backup saved to file'** + String get backupSavedToFile; + + /// No description provided for @restoreFromFile. + /// + /// In en, this message translates to: + /// **'Restore from file'** + String get restoreFromFile; + + /// No description provided for @backupFileReadFailed. + /// + /// In en, this message translates to: + /// **'Could not read the selected backup file.'** + String get backupFileReadFailed; + + /// No description provided for @fetchingWalletConnectionInfo. + /// + /// In en, this message translates to: + /// **'Fetching wallet connection info…'** + String get fetchingWalletConnectionInfo; + /// Button text for creating a new account /// /// In en, this message translates to: @@ -2027,6 +2057,144 @@ abstract class AppLocalizations { /// **'Add Wallet'** String get addWalletTitle; + /// Description for the unified add wallet flow + /// + /// In en, this message translates to: + /// **'Scan any supported wallet QR code, paste its details, or connect through a wallet app.'** + String get addWalletDescription; + + /// Button for opening the universal wallet QR scanner + /// + /// In en, this message translates to: + /// **'Scan wallet QR code'** + String get scanWalletQrCode; + + /// Heading for wallet-assisted NWC connection options + /// + /// In en, this message translates to: + /// **'Connect with a wallet'** + String get connectWithWallet; + + /// Button for opening the standard NWC wallet chooser + /// + /// In en, this message translates to: + /// **'Choose wallet app'** + String get chooseWalletApp; + + /// No description provided for @oneClickConnect. + /// + /// In en, this message translates to: + /// **'1-click connect'** + String get oneClickConnect; + + /// No description provided for @chooseWallet. + /// + /// In en, this message translates to: + /// **'Choose wallet'** + String get chooseWallet; + + /// No description provided for @albyWalletOption. + /// + /// In en, this message translates to: + /// **'Alby'** + String get albyWalletOption; + + /// No description provided for @albyCloudOption. + /// + /// In en, this message translates to: + /// **'Alby Cloud'** + String get albyCloudOption; + + /// No description provided for @coinosWalletOption. + /// + /// In en, this message translates to: + /// **'Coinos'** + String get coinosWalletOption; + + /// No description provided for @manualNwcConnection. + /// + /// In en, this message translates to: + /// **'Manual NWC connection'** + String get manualNwcConnection; + + /// Prompt shown while an external wallet is authorizing + /// + /// In en, this message translates to: + /// **'Finish connection in {walletName}'** + String walletConnectionFinishIn(String walletName); + + /// Progress shown while adding an externally authorized wallet + /// + /// In en, this message translates to: + /// **'Connecting {walletName}…'** + String walletConnectionConnecting(String walletName); + + /// Animated success message after adding a wallet + /// + /// In en, this message translates to: + /// **'{walletName} connected'** + String walletConnectionConnected(String walletName); + + /// Heading shown when an external wallet connection fails + /// + /// In en, this message translates to: + /// **'Could not connect {walletName}'** + String walletConnectionFailed(String walletName); + + /// No description provided for @retry. + /// + /// In en, this message translates to: + /// **'Retry'** + String get retry; + + /// No description provided for @chooseAnotherWallet. + /// + /// In en, this message translates to: + /// **'Choose another wallet'** + String get chooseAnotherWallet; + + /// Description for the standard NWC wallet chooser + /// + /// In en, this message translates to: + /// **'Approve an NWC connection in an installed wallet'** + String get chooseWalletAppDescription; + + /// Label for unified wallet input + /// + /// In en, this message translates to: + /// **'Wallet address or connection'** + String get walletInput; + + /// Hint listing supported wallet inputs + /// + /// In en, this message translates to: + /// **'NWC, Lightning/BIP353 address, BOLT12/BIP321 offer, or HTTPS Cashu mint URL'** + String get walletInputHint; + + /// Error for an unrecognized unified wallet input + /// + /// In en, this message translates to: + /// **'This is not a supported wallet address or connection.'** + String get unsupportedWalletInput; + + /// Label shown before a detected wallet type + /// + /// In en, this message translates to: + /// **'Detected'** + String get detected; + + /// Detected type label for an ambiguous user at domain address + /// + /// In en, this message translates to: + /// **'Lightning or BIP353 address'** + String get lightningAddressInputType; + + /// Button revealing type-specific manual wallet setup + /// + /// In en, this message translates to: + /// **'Set up manually'** + String get manualWalletSetup; + /// Prompt to choose wallet type /// /// In en, this message translates to: @@ -2063,6 +2231,48 @@ abstract class AppLocalizations { /// **'Cashu'** String get cashuWalletTypeTitle; + /// No description provided for @chooseCashuMint. + /// + /// In en, this message translates to: + /// **'Choose Cashu mint'** + String get chooseCashuMint; + + /// No description provided for @cashuMintRatingsNotice. + /// + /// In en, this message translates to: + /// **'Community ratings come from signed Nostr reviews. A high rating does not guarantee that a mint is safe.'** + String get cashuMintRatingsNotice; + + /// No description provided for @cashuMintDiscoveryFailed. + /// + /// In en, this message translates to: + /// **'Could not load mint suggestions.'** + String get cashuMintDiscoveryFailed; + + /// No description provided for @noCashuMintSuggestions. + /// + /// In en, this message translates to: + /// **'No available mint suggestions found.'** + String get noCashuMintSuggestions; + + /// No description provided for @noRatingsYet. + /// + /// In en, this message translates to: + /// **'No ratings yet'** + String get noRatingsYet; + + /// No description provided for @cashuMintRating. + /// + /// In en, this message translates to: + /// **'★ {rating} · {count} reviews'** + String cashuMintRating(String rating, int count); + + /// No description provided for @enterMintUrlManually. + /// + /// In en, this message translates to: + /// **'Enter mint URL manually'** + String get enterMintUrlManually; + /// Subtitle for the Cashu wallet type option /// /// In en, this message translates to: @@ -2387,6 +2597,18 @@ abstract class AppLocalizations { /// **'BOLT12 Offer'** String get bolt12WalletTypeTitle; + /// No description provided for @bip353WalletTypeTitle. + /// + /// In en, this message translates to: + /// **'BIP353'** + String get bip353WalletTypeTitle; + + /// No description provided for @lnurlProtocol. + /// + /// In en, this message translates to: + /// **'LNURL'** + String get lnurlProtocol; + /// No description provided for @bolt12WalletTypeSubtitle. /// /// In en, this message translates to: @@ -2458,6 +2680,174 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Share this reusable offer to receive a Lightning payment.'** String get bolt12OfferInstructions; + + /// No description provided for @confirm. + /// + /// In en, this message translates to: + /// **'Confirm'** + String get confirm; + + /// No description provided for @reviewWallet. + /// + /// In en, this message translates to: + /// **'Review wallet'** + String get reviewWallet; + + /// No description provided for @confirmWalletTitle. + /// + /// In en, this message translates to: + /// **'Confirm wallet'** + String get confirmWalletTitle; + + /// No description provided for @confirmWalletDescription. + /// + /// In en, this message translates to: + /// **'Review these details before adding this wallet.'** + String get confirmWalletDescription; + + /// No description provided for @walletDetailType. + /// + /// In en, this message translates to: + /// **'Wallet type'** + String get walletDetailType; + + /// No description provided for @walletDetailAddress. + /// + /// In en, this message translates to: + /// **'Address'** + String get walletDetailAddress; + + /// No description provided for @walletDetailDomain. + /// + /// In en, this message translates to: + /// **'Domain'** + String get walletDetailDomain; + + /// No description provided for @walletDetailUrl. + /// + /// In en, this message translates to: + /// **'URL'** + String get walletDetailUrl; + + /// No description provided for @walletDetailPublicKey. + /// + /// In en, this message translates to: + /// **'Public key'** + String get walletDetailPublicKey; + + /// No description provided for @walletDetailRelay. + /// + /// In en, this message translates to: + /// **'Relay'** + String get walletDetailRelay; + + /// No description provided for @walletDetailRelays. + /// + /// In en, this message translates to: + /// **'Relays'** + String get walletDetailRelays; + + /// No description provided for @walletDetailSecret. + /// + /// In en, this message translates to: + /// **'Connection secret'** + String get walletDetailSecret; + + /// No description provided for @walletSecretHidden. + /// + /// In en, this message translates to: + /// **'Present and hidden for security'** + String get walletSecretHidden; + + /// No description provided for @walletDetailDescription. + /// + /// In en, this message translates to: + /// **'Description'** + String get walletDetailDescription; + + /// No description provided for @walletDetailDetails. + /// + /// In en, this message translates to: + /// **'Details'** + String get walletDetailDetails; + + /// No description provided for @walletDetailIssuer. + /// + /// In en, this message translates to: + /// **'Issuer'** + String get walletDetailIssuer; + + /// No description provided for @walletDetailAmount. + /// + /// In en, this message translates to: + /// **'Amount'** + String get walletDetailAmount; + + /// No description provided for @walletDetailCurrency. + /// + /// In en, this message translates to: + /// **'Currency'** + String get walletDetailCurrency; + + /// No description provided for @walletDetailExpiry. + /// + /// In en, this message translates to: + /// **'Expires'** + String get walletDetailExpiry; + + /// No description provided for @walletDetailNodeId. + /// + /// In en, this message translates to: + /// **'Node ID'** + String get walletDetailNodeId; + + /// No description provided for @walletDetailOffer. + /// + /// In en, this message translates to: + /// **'BOLT12 offer'** + String get walletDetailOffer; + + /// No description provided for @walletDetailVersion. + /// + /// In en, this message translates to: + /// **'Version'** + String get walletDetailVersion; + + /// No description provided for @walletDetailUnits. + /// + /// In en, this message translates to: + /// **'Supported units'** + String get walletDetailUnits; + + /// No description provided for @walletDetailContact. + /// + /// In en, this message translates to: + /// **'Contact'** + String get walletDetailContact; + + /// No description provided for @walletDetailTerms. + /// + /// In en, this message translates to: + /// **'Terms of service'** + String get walletDetailTerms; + + /// No description provided for @walletDetailMessage. + /// + /// In en, this message translates to: + /// **'Message'** + String get walletDetailMessage; + + /// No description provided for @walletDetailCommunityRating. + /// + /// In en, this message translates to: + /// **'Community rating'** + String get walletDetailCommunityRating; + + /// No description provided for @walletDetailCommunityReviews. + /// + /// In en, this message translates to: + /// **'Recent community reviews'** + String get walletDetailCommunityReviews; } 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 47592bc49..3a4d349aa 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_de.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_de.dart @@ -8,6 +8,23 @@ import 'app_localizations.dart'; class AppLocalizationsDe extends AppLocalizations { AppLocalizationsDe([String locale = 'de']) : super(locale); + @override + String get saveBackupToFile => 'Backup in Datei speichern'; + + @override + String get backupSavedToFile => 'Backup in Datei gespeichert'; + + @override + String get restoreFromFile => 'Aus Datei wiederherstellen'; + + @override + String get backupFileReadFailed => + 'Die ausgewählte Backup-Datei konnte nicht gelesen werden.'; + + @override + String get fetchingWalletConnectionInfo => + 'Wallet-Verbindungsdaten werden abgerufen…'; + @override String get createAccount => 'Konto erstellen'; @@ -772,26 +789,24 @@ class AppLocalizationsDe extends AppLocalizations { String get payInvoiceTitle => 'Rechnung bezahlen'; @override - String get sendToWallet => 'An eine Wallet senden'; + String get sendToWallet => 'Send to Wallet'; @override - String get sendToWalletDescription => - 'Auf eine andere kompatible Wallet übertragen'; + String get sendToWalletDescription => 'Transfer to another compatible wallet'; @override - String get noCompatibleReceivingWallets => - 'Keine kompatiblen Empfangs-Wallets'; + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; @override String get noCompatibleReceivingWalletsDescription => - 'Füge eine andere Wallet hinzu oder verbinde eine, die eine von dieser Wallet unterstützte Zahlung empfangen kann.'; + 'Add or connect another wallet that can receive a payment supported by this wallet.'; @override - String get destinationWallet => 'Ziel-Wallet'; + String get destinationWallet => 'Destination wallet'; @override String walletTransferSubmitted(String walletName) { - return 'Zahlung an $walletName gesendet'; + return 'Payment sent to $walletName'; } @override @@ -990,6 +1005,87 @@ class AppLocalizationsDe extends AppLocalizations { @override String get addWalletTitle => 'Wallet hinzufügen'; + @override + String get addWalletDescription => + 'Scanne einen unterstützten Wallet-QR-Code, füge Verbindungsdaten ein oder verbinde dich über eine Wallet-App.'; + + @override + String get scanWalletQrCode => 'Wallet-QR-Code scannen'; + + @override + String get connectWithWallet => 'Mit einer Wallet verbinden'; + + @override + String get chooseWalletApp => 'Wallet-App auswählen'; + + @override + String get oneClickConnect => 'Mit 1 Klick verbinden'; + + @override + String get chooseWallet => 'Wallet auswählen'; + + @override + String get albyWalletOption => 'Alby'; + + @override + String get albyCloudOption => 'Alby Cloud'; + + @override + String get coinosWalletOption => 'Coinos'; + + @override + String get manualNwcConnection => 'Manuelle NWC-Verbindung'; + + @override + String walletConnectionFinishIn(String walletName) { + return 'Verbindung in $walletName abschließen'; + } + + @override + String walletConnectionConnecting(String walletName) { + return '$walletName wird verbunden…'; + } + + @override + String walletConnectionConnected(String walletName) { + return '$walletName verbunden'; + } + + @override + String walletConnectionFailed(String walletName) { + return '$walletName konnte nicht verbunden werden'; + } + + @override + String get retry => 'Erneut versuchen'; + + @override + String get chooseAnotherWallet => 'Andere Wallet auswählen'; + + @override + String get chooseWalletAppDescription => + 'Eine NWC-Verbindung in einer installierten Wallet genehmigen'; + + @override + String get walletInput => 'Wallet-Adresse oder Verbindung'; + + @override + String get walletInputHint => + 'NWC, Lightning-/BIP353-Adresse, BOLT12-/BIP321-Angebot oder HTTPS-URL eines Cashu-Mints'; + + @override + String get unsupportedWalletInput => + 'Dies ist keine unterstützte Wallet-Adresse oder Verbindung.'; + + @override + String get detected => 'Erkannt'; + + @override + String get lightningAddressInputType => 'Lightning- oder BIP353-Adresse'; + + @override + String get manualWalletSetup => 'Manuell einrichten'; + @override String get chooseWalletType => 'Wallet-Typ wählen'; @@ -1010,6 +1106,32 @@ class AppLocalizationsDe extends AppLocalizations { @override String get cashuWalletTypeTitle => 'Cashu'; + @override + String get chooseCashuMint => 'Cashu-Mint auswählen'; + + @override + String get cashuMintRatingsNotice => + 'Community-Bewertungen stammen aus signierten Nostr-Rezensionen. Eine hohe Bewertung garantiert nicht, dass ein Mint sicher ist.'; + + @override + String get cashuMintDiscoveryFailed => + 'Mint-Vorschläge konnten nicht geladen werden.'; + + @override + String get noCashuMintSuggestions => + 'Keine verfügbaren Mint-Vorschläge gefunden.'; + + @override + String get noRatingsYet => 'Noch keine Bewertungen'; + + @override + String cashuMintRating(String rating, int count) { + return '★ $rating · $count Bewertungen'; + } + + @override + String get enterMintUrlManually => 'Mint-URL manuell eingeben'; + @override String get cashuWalletTypeSubtitle => 'Eine Ecash-Wallet mit einer Cashu-Mint verwenden'; @@ -1163,58 +1285,62 @@ class AppLocalizationsDe extends AppLocalizations { String get bolt12Wallet => 'BOLT12-Wallet'; @override - String get bolt12WalletSubtitle => 'Wiederverwendbares Lightning-Angebot'; + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; @override - String get bolt12PrivateOfferSubtitle => - 'Wiederverwendbares privates Angebot'; + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; @override - String get anyAmount => 'Beliebiger Betrag'; + String get anyAmount => 'Any amount'; @override - String get blindedRoute => 'Verblindete Route'; + String get blindedRoute => 'Blinded'; @override String fromAmountSats(String amount) { - return 'Ab $amount Sats'; + return 'From $amount sats'; } @override String fromAmountMsats(String amount) { - return 'Ab $amount msats'; + return 'From $amount msats'; } @override String fromCurrencyAmount(String amount, String currency) { - return 'Ab $amount $currency'; + return 'From $amount $currency'; } @override String bolt12Expires(String date) { - return 'Läuft am $date ab'; + return 'Expires $date'; } @override String get bolt12WalletTypeTitle => 'BOLT12-Angebot'; + @override + String get bip353WalletTypeTitle => 'BIP353'; + + @override + String get lnurlProtocol => 'LNURL'; + @override String get bolt12WalletTypeSubtitle => - 'Nur-Empfangs-Wallet mit einem wiederverwendbaren Angebot'; + 'Receive-only wallet using a reusable offer'; @override String get addBolt12WalletTitle => 'BOLT12-Wallet hinzufügen'; @override String get enterBolt12Input => - 'Gib ein lno-Angebot, eine bitcoin:?lno=...-URI oder eine BIP353-Adresse ein oder scanne sie.'; + 'Gib ein lno-Angebot, einen bitcoin:?lno=…-URI oder eine BIP353-Adresse ein oder scanne sie.'; @override String get bolt12Input => 'BOLT12-Zahlungsziel'; @override - String get bolt12InputHint => - 'lno1..., bitcoin:?lno=... oder user@domain.com'; + String get bolt12InputHint => 'lno1…, bitcoin:?lno=… oder user@domain.com'; @override String get walletNameOptional => 'Wallet-Name (optional)'; @@ -1224,7 +1350,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get invalidBolt12QrCode => - 'Der QR-Code ist kein BOLT12-, BIP321- oder BIP353-Zahlungsziel.'; + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; @override String get pleaseEnterBolt12Input => @@ -1234,9 +1360,95 @@ class AppLocalizationsDe extends AppLocalizations { String get bolt12WalletAdded => 'BOLT12-Wallet erfolgreich hinzugefügt!'; @override - String get bolt12OfferTitle => 'Mit BOLT12 empfangen'; + String get bolt12OfferTitle => 'Receive with BOLT12'; @override String get bolt12OfferInstructions => - 'Teile dieses wiederverwendbare Angebot, um eine Lightning-Zahlung zu empfangen.'; + 'Share this reusable offer to receive a Lightning payment.'; + + @override + String get confirm => 'Bestätigen'; + + @override + String get reviewWallet => 'Wallet prüfen'; + + @override + String get confirmWalletTitle => 'Wallet bestätigen'; + + @override + String get confirmWalletDescription => + 'Prüfe diese Angaben, bevor du die Wallet hinzufügst.'; + + @override + String get walletDetailType => 'Wallet-Typ'; + + @override + String get walletDetailAddress => 'Adresse'; + + @override + String get walletDetailDomain => 'Domain'; + + @override + String get walletDetailUrl => 'URL'; + + @override + String get walletDetailPublicKey => 'Öffentlicher Schlüssel'; + + @override + String get walletDetailRelay => 'Relay'; + + @override + String get walletDetailRelays => 'Relays'; + + @override + String get walletDetailSecret => 'Verbindungsgeheimnis'; + + @override + String get walletSecretHidden => + 'Vorhanden und aus Sicherheitsgründen ausgeblendet'; + + @override + String get walletDetailDescription => 'Beschreibung'; + + @override + String get walletDetailDetails => 'Details'; + + @override + String get walletDetailIssuer => 'Aussteller'; + + @override + String get walletDetailAmount => 'Betrag'; + + @override + String get walletDetailCurrency => 'Währung'; + + @override + String get walletDetailExpiry => 'Läuft ab'; + + @override + String get walletDetailNodeId => 'Node-ID'; + + @override + String get walletDetailOffer => 'BOLT12-Angebot'; + + @override + String get walletDetailVersion => 'Version'; + + @override + String get walletDetailUnits => 'Unterstützte Einheiten'; + + @override + String get walletDetailContact => 'Kontakt'; + + @override + String get walletDetailTerms => 'Nutzungsbedingungen'; + + @override + String get walletDetailMessage => 'Nachricht'; + + @override + String get walletDetailCommunityRating => 'Community-Bewertung'; + + @override + String get walletDetailCommunityReviews => 'Aktuelle Community-Rezensionen'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_en.dart b/packages/ndk_flutter/lib/l10n/app_localizations_en.dart index 3783ee751..a8daa84b1 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_en.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_en.dart @@ -8,6 +8,21 @@ import 'app_localizations.dart'; class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); + @override + String get saveBackupToFile => 'Save backup to file'; + + @override + String get backupSavedToFile => 'Backup saved to file'; + + @override + String get restoreFromFile => 'Restore from file'; + + @override + String get backupFileReadFailed => 'Could not read the selected backup file.'; + + @override + String get fetchingWalletConnectionInfo => 'Fetching wallet connection info…'; + @override String get createAccount => 'Create your account'; @@ -986,6 +1001,87 @@ class AppLocalizationsEn extends AppLocalizations { @override String get addWalletTitle => 'Add Wallet'; + @override + String get addWalletDescription => + 'Scan any supported wallet QR code, paste its details, or connect through a wallet app.'; + + @override + String get scanWalletQrCode => 'Scan wallet QR code'; + + @override + String get connectWithWallet => 'Connect with a wallet'; + + @override + String get chooseWalletApp => 'Choose wallet app'; + + @override + String get oneClickConnect => '1-click connect'; + + @override + String get chooseWallet => 'Choose wallet'; + + @override + String get albyWalletOption => 'Alby'; + + @override + String get albyCloudOption => 'Alby Cloud'; + + @override + String get coinosWalletOption => 'Coinos'; + + @override + String get manualNwcConnection => 'Manual NWC connection'; + + @override + String walletConnectionFinishIn(String walletName) { + return 'Finish connection in $walletName'; + } + + @override + String walletConnectionConnecting(String walletName) { + return 'Connecting $walletName…'; + } + + @override + String walletConnectionConnected(String walletName) { + return '$walletName connected'; + } + + @override + String walletConnectionFailed(String walletName) { + return 'Could not connect $walletName'; + } + + @override + String get retry => 'Retry'; + + @override + String get chooseAnotherWallet => 'Choose another wallet'; + + @override + String get chooseWalletAppDescription => + 'Approve an NWC connection in an installed wallet'; + + @override + String get walletInput => 'Wallet address or connection'; + + @override + String get walletInputHint => + 'NWC, Lightning/BIP353 address, BOLT12/BIP321 offer, or HTTPS Cashu mint URL'; + + @override + String get unsupportedWalletInput => + 'This is not a supported wallet address or connection.'; + + @override + String get detected => 'Detected'; + + @override + String get lightningAddressInputType => 'Lightning or BIP353 address'; + + @override + String get manualWalletSetup => 'Set up manually'; + @override String get chooseWalletType => 'Choose wallet type'; @@ -1005,6 +1101,30 @@ class AppLocalizationsEn extends AppLocalizations { @override String get cashuWalletTypeTitle => 'Cashu'; + @override + String get chooseCashuMint => 'Choose Cashu mint'; + + @override + String get cashuMintRatingsNotice => + 'Community ratings come from signed Nostr reviews. A high rating does not guarantee that a mint is safe.'; + + @override + String get cashuMintDiscoveryFailed => 'Could not load mint suggestions.'; + + @override + String get noCashuMintSuggestions => 'No available mint suggestions found.'; + + @override + String get noRatingsYet => 'No ratings yet'; + + @override + String cashuMintRating(String rating, int count) { + return '★ $rating · $count reviews'; + } + + @override + String get enterMintUrlManually => 'Enter mint URL manually'; + @override String get cashuWalletTypeSubtitle => 'Use an ecash wallet backed by a Cashu mint'; @@ -1191,6 +1311,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + @override + String get bip353WalletTypeTitle => 'BIP353'; + + @override + String get lnurlProtocol => 'LNURL'; + @override String get bolt12WalletTypeSubtitle => 'Receive-only wallet using a reusable offer'; @@ -1231,4 +1357,89 @@ class AppLocalizationsEn extends AppLocalizations { @override String get bolt12OfferInstructions => 'Share this reusable offer to receive a Lightning payment.'; + + @override + String get confirm => 'Confirm'; + + @override + String get reviewWallet => 'Review wallet'; + + @override + String get confirmWalletTitle => 'Confirm wallet'; + + @override + String get confirmWalletDescription => + 'Review these details before adding this wallet.'; + + @override + String get walletDetailType => 'Wallet type'; + + @override + String get walletDetailAddress => 'Address'; + + @override + String get walletDetailDomain => 'Domain'; + + @override + String get walletDetailUrl => 'URL'; + + @override + String get walletDetailPublicKey => 'Public key'; + + @override + String get walletDetailRelay => 'Relay'; + + @override + String get walletDetailRelays => 'Relays'; + + @override + String get walletDetailSecret => 'Connection secret'; + + @override + String get walletSecretHidden => 'Present and hidden for security'; + + @override + String get walletDetailDescription => 'Description'; + + @override + String get walletDetailDetails => 'Details'; + + @override + String get walletDetailIssuer => 'Issuer'; + + @override + String get walletDetailAmount => 'Amount'; + + @override + String get walletDetailCurrency => 'Currency'; + + @override + String get walletDetailExpiry => 'Expires'; + + @override + String get walletDetailNodeId => 'Node ID'; + + @override + String get walletDetailOffer => 'BOLT12 offer'; + + @override + String get walletDetailVersion => 'Version'; + + @override + String get walletDetailUnits => 'Supported units'; + + @override + String get walletDetailContact => 'Contact'; + + @override + String get walletDetailTerms => 'Terms of service'; + + @override + String get walletDetailMessage => 'Message'; + + @override + String get walletDetailCommunityRating => 'Community rating'; + + @override + String get walletDetailCommunityReviews => 'Recent community reviews'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_es.dart b/packages/ndk_flutter/lib/l10n/app_localizations_es.dart index 018c69aab..c5e5a92a3 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_es.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_es.dart @@ -8,6 +8,23 @@ import 'app_localizations.dart'; class AppLocalizationsEs extends AppLocalizations { AppLocalizationsEs([String locale = 'es']) : super(locale); + @override + String get saveBackupToFile => 'Guardar copia en un archivo'; + + @override + String get backupSavedToFile => 'Copia guardada en un archivo'; + + @override + String get restoreFromFile => 'Restaurar desde un archivo'; + + @override + String get backupFileReadFailed => + 'No se pudo leer el archivo de copia seleccionado.'; + + @override + String get fetchingWalletConnectionInfo => + 'Obteniendo información de conexión de la cartera…'; + @override String get createAccount => 'Crear tu cuenta'; @@ -774,25 +791,24 @@ class AppLocalizationsEs extends AppLocalizations { String get payInvoiceTitle => 'Pagar Factura'; @override - String get sendToWallet => 'Enviar a una cartera'; + String get sendToWallet => 'Send to Wallet'; @override - String get sendToWalletDescription => 'Transferir a otra cartera compatible'; + String get sendToWalletDescription => 'Transfer to another compatible wallet'; @override - String get noCompatibleReceivingWallets => - 'No hay carteras receptoras compatibles'; + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; @override String get noCompatibleReceivingWalletsDescription => - 'Añade o conecta otra cartera que pueda recibir un pago admitido por esta cartera.'; + 'Add or connect another wallet that can receive a payment supported by this wallet.'; @override - String get destinationWallet => 'Cartera de destino'; + String get destinationWallet => 'Destination wallet'; @override String walletTransferSubmitted(String walletName) { - return 'Pago enviado a $walletName'; + return 'Payment sent to $walletName'; } @override @@ -991,6 +1007,87 @@ class AppLocalizationsEs extends AppLocalizations { @override String get addWalletTitle => 'Añadir Cartera'; + @override + String get addWalletDescription => + 'Escanea un código QR de cartera compatible, pega sus datos o conéctate mediante una aplicación de cartera.'; + + @override + String get scanWalletQrCode => 'Escanear QR de cartera'; + + @override + String get connectWithWallet => 'Conectar con una cartera'; + + @override + String get chooseWalletApp => 'Elegir aplicación de cartera'; + + @override + String get oneClickConnect => 'Conectar con 1 clic'; + + @override + String get chooseWallet => 'Elegir cartera'; + + @override + String get albyWalletOption => 'Alby'; + + @override + String get albyCloudOption => 'Alby Cloud'; + + @override + String get coinosWalletOption => 'Coinos'; + + @override + String get manualNwcConnection => 'Conexión NWC manual'; + + @override + String walletConnectionFinishIn(String walletName) { + return 'Finaliza la conexión en $walletName'; + } + + @override + String walletConnectionConnecting(String walletName) { + return 'Conectando $walletName…'; + } + + @override + String walletConnectionConnected(String walletName) { + return '$walletName conectada'; + } + + @override + String walletConnectionFailed(String walletName) { + return 'No se pudo conectar $walletName'; + } + + @override + String get retry => 'Reintentar'; + + @override + String get chooseAnotherWallet => 'Elegir otra cartera'; + + @override + String get chooseWalletAppDescription => + 'Aprueba una conexión NWC en una cartera instalada'; + + @override + String get walletInput => 'Dirección o conexión de cartera'; + + @override + String get walletInputHint => + 'NWC, dirección Lightning/BIP353, oferta BOLT12/BIP321 o URL HTTPS de un mint Cashu'; + + @override + String get unsupportedWalletInput => + 'Esta dirección o conexión de cartera no es compatible.'; + + @override + String get detected => 'Detectado'; + + @override + String get lightningAddressInputType => 'Dirección Lightning o BIP353'; + + @override + String get manualWalletSetup => 'Configurar manualmente'; + @override String get chooseWalletType => 'Elegir tipo de cartera'; @@ -1010,6 +1107,32 @@ class AppLocalizationsEs extends AppLocalizations { @override String get cashuWalletTypeTitle => 'Cashu'; + @override + String get chooseCashuMint => 'Elegir mint Cashu'; + + @override + String get cashuMintRatingsNotice => + 'Las valoraciones de la comunidad proceden de reseñas Nostr firmadas. Una valoración alta no garantiza que un mint sea seguro.'; + + @override + String get cashuMintDiscoveryFailed => + 'No se pudieron cargar las sugerencias de mints.'; + + @override + String get noCashuMintSuggestions => + 'No se encontraron sugerencias de mints disponibles.'; + + @override + String get noRatingsYet => 'Aún sin valoraciones'; + + @override + String cashuMintRating(String rating, int count) { + return '★ $rating · $count reseñas'; + } + + @override + String get enterMintUrlManually => 'Introducir URL del mint manualmente'; + @override String get cashuWalletTypeSubtitle => 'Usar una cartera ecash respaldada por una mint de Cashu'; @@ -1163,56 +1286,62 @@ class AppLocalizationsEs extends AppLocalizations { String get bolt12Wallet => 'Cartera BOLT12'; @override - String get bolt12WalletSubtitle => 'Oferta Lightning reutilizable'; + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; @override - String get bolt12PrivateOfferSubtitle => 'Oferta privada reutilizable'; + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; @override - String get anyAmount => 'Cualquier importe'; + String get anyAmount => 'Any amount'; @override - String get blindedRoute => 'Ruta cegada'; + String get blindedRoute => 'Blinded'; @override String fromAmountSats(String amount) { - return 'Desde $amount sats'; + return 'From $amount sats'; } @override String fromAmountMsats(String amount) { - return 'Desde $amount msats'; + return 'From $amount msats'; } @override String fromCurrencyAmount(String amount, String currency) { - return 'Desde $amount $currency'; + return 'From $amount $currency'; } @override String bolt12Expires(String date) { - return 'Caduca el $date'; + return 'Expires $date'; } @override String get bolt12WalletTypeTitle => 'Oferta BOLT12'; + @override + String get bip353WalletTypeTitle => 'BIP353'; + + @override + String get lnurlProtocol => 'LNURL'; + @override String get bolt12WalletTypeSubtitle => - 'Cartera de solo recepción que usa una oferta reutilizable'; + 'Receive-only wallet using a reusable offer'; @override String get addBolt12WalletTitle => 'Añadir cartera BOLT12'; @override String get enterBolt12Input => - 'Introduce o escanea una oferta lno, un URI bitcoin:?lno=... o una dirección BIP353.'; + 'Introduce o escanea una oferta lno, un URI bitcoin:?lno=… o una dirección BIP353.'; @override String get bolt12Input => 'Destino de pago BOLT12'; @override - String get bolt12InputHint => 'lno1..., bitcoin:?lno=... o user@domain.com'; + String get bolt12InputHint => 'lno1…, bitcoin:?lno=… o usuario@dominio.com'; @override String get walletNameOptional => 'Nombre de la cartera (opcional)'; @@ -1222,7 +1351,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get invalidBolt12QrCode => - 'El código QR no es un destino de pago BOLT12, BIP321 ni BIP353.'; + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; @override String get pleaseEnterBolt12Input => @@ -1232,9 +1361,95 @@ class AppLocalizationsEs extends AppLocalizations { String get bolt12WalletAdded => '¡Cartera BOLT12 añadida correctamente!'; @override - String get bolt12OfferTitle => 'Recibir con BOLT12'; + String get bolt12OfferTitle => 'Receive with BOLT12'; @override String get bolt12OfferInstructions => - 'Comparte esta oferta reutilizable para recibir un pago Lightning.'; + 'Share this reusable offer to receive a Lightning payment.'; + + @override + String get confirm => 'Confirmar'; + + @override + String get reviewWallet => 'Revisar cartera'; + + @override + String get confirmWalletTitle => 'Confirmar cartera'; + + @override + String get confirmWalletDescription => + 'Revisa estos datos antes de añadir la cartera.'; + + @override + String get walletDetailType => 'Tipo de cartera'; + + @override + String get walletDetailAddress => 'Dirección'; + + @override + String get walletDetailDomain => 'Dominio'; + + @override + String get walletDetailUrl => 'URL'; + + @override + String get walletDetailPublicKey => 'Clave pública'; + + @override + String get walletDetailRelay => 'Relay'; + + @override + String get walletDetailRelays => 'Relays'; + + @override + String get walletDetailSecret => 'Secreto de conexión'; + + @override + String get walletSecretHidden => 'Presente y oculto por seguridad'; + + @override + String get walletDetailDescription => 'Descripción'; + + @override + String get walletDetailDetails => 'Detalles'; + + @override + String get walletDetailIssuer => 'Emisor'; + + @override + String get walletDetailAmount => 'Importe'; + + @override + String get walletDetailCurrency => 'Moneda'; + + @override + String get walletDetailExpiry => 'Caduca'; + + @override + String get walletDetailNodeId => 'ID del nodo'; + + @override + String get walletDetailOffer => 'Oferta BOLT12'; + + @override + String get walletDetailVersion => 'Versión'; + + @override + String get walletDetailUnits => 'Unidades compatibles'; + + @override + String get walletDetailContact => 'Contacto'; + + @override + String get walletDetailTerms => 'Términos del servicio'; + + @override + String get walletDetailMessage => 'Mensaje'; + + @override + String get walletDetailCommunityRating => 'Valoración de la comunidad'; + + @override + String get walletDetailCommunityReviews => + 'Reseñas recientes de la comunidad'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart b/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart index 6fdf86e12..082a74f88 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart @@ -8,6 +8,22 @@ import 'app_localizations.dart'; class AppLocalizationsFi extends AppLocalizations { AppLocalizationsFi([String locale = 'fi']) : super(locale); + @override + String get saveBackupToFile => 'Tallenna varmuuskopio tiedostoon'; + + @override + String get backupSavedToFile => 'Varmuuskopio tallennettu tiedostoon'; + + @override + String get restoreFromFile => 'Palauta tiedostosta'; + + @override + String get backupFileReadFailed => + 'Valittua varmuuskopiotiedostoa ei voitu lukea.'; + + @override + String get fetchingWalletConnectionInfo => 'Haetaan lompakon yhteystietoja…'; + @override String get createAccount => 'Luo tili'; @@ -772,26 +788,24 @@ class AppLocalizationsFi extends AppLocalizations { String get payInvoiceTitle => 'Maksa lasku'; @override - String get sendToWallet => 'Lähetä lompakkoon'; + String get sendToWallet => 'Send to Wallet'; @override - String get sendToWalletDescription => - 'Siirrä toiseen yhteensopivaan lompakkoon'; + String get sendToWalletDescription => 'Transfer to another compatible wallet'; @override - String get noCompatibleReceivingWallets => - 'Ei yhteensopivia vastaanottavia lompakoita'; + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; @override String get noCompatibleReceivingWalletsDescription => - 'Lisää tai yhdistä toinen lompakko, joka voi vastaanottaa tämän lompakon tukeman maksun.'; + 'Add or connect another wallet that can receive a payment supported by this wallet.'; @override - String get destinationWallet => 'Kohdelompakko'; + String get destinationWallet => 'Destination wallet'; @override String walletTransferSubmitted(String walletName) { - return 'Maksu lähetetty lompakkoon $walletName'; + return 'Payment sent to $walletName'; } @override @@ -989,6 +1003,87 @@ class AppLocalizationsFi extends AppLocalizations { @override String get addWalletTitle => 'Lisää lompakko'; + @override + String get addWalletDescription => + 'Skannaa tuetun lompakon QR-koodi, liitä sen tiedot tai yhdistä lompakkosovelluksella.'; + + @override + String get scanWalletQrCode => 'Skannaa lompakon QR-koodi'; + + @override + String get connectWithWallet => 'Yhdistä lompakkoon'; + + @override + String get chooseWalletApp => 'Valitse lompakkosovellus'; + + @override + String get oneClickConnect => 'Yhdistä yhdellä napsautuksella'; + + @override + String get chooseWallet => 'Valitse lompakko'; + + @override + String get albyWalletOption => 'Alby'; + + @override + String get albyCloudOption => 'Alby Cloud'; + + @override + String get coinosWalletOption => 'Coinos'; + + @override + String get manualNwcConnection => 'Manuaalinen NWC-yhteys'; + + @override + String walletConnectionFinishIn(String walletName) { + return 'Viimeistele yhteys sovelluksessa $walletName'; + } + + @override + String walletConnectionConnecting(String walletName) { + return 'Yhdistetään lompakkoon $walletName…'; + } + + @override + String walletConnectionConnected(String walletName) { + return '$walletName yhdistetty'; + } + + @override + String walletConnectionFailed(String walletName) { + return 'Lompakkoon $walletName ei voitu yhdistää'; + } + + @override + String get retry => 'Yritä uudelleen'; + + @override + String get chooseAnotherWallet => 'Valitse toinen lompakko'; + + @override + String get chooseWalletAppDescription => + 'Hyväksy NWC-yhteys asennetussa lompakossa'; + + @override + String get walletInput => 'Lompakon osoite tai yhteys'; + + @override + String get walletInputHint => + 'NWC, Lightning-/BIP353-osoite, BOLT12-/BIP321-tarjous tai Cashu-mintin HTTPS-URL'; + + @override + String get unsupportedWalletInput => + 'Tätä lompakon osoitetta tai yhteyttä ei tueta.'; + + @override + String get detected => 'Havaittu'; + + @override + String get lightningAddressInputType => 'Lightning- tai BIP353-osoite'; + + @override + String get manualWalletSetup => 'Määritä manuaalisesti'; + @override String get chooseWalletType => 'Valitse lompakon tyyppi'; @@ -1008,6 +1103,31 @@ class AppLocalizationsFi extends AppLocalizations { @override String get cashuWalletTypeTitle => 'Cashu'; + @override + String get chooseCashuMint => 'Valitse Cashu-mintti'; + + @override + String get cashuMintRatingsNotice => + 'Yhteisöarviot ovat allekirjoitettuja Nostr-arvosteluja. Korkea arvio ei takaa mintin turvallisuutta.'; + + @override + String get cashuMintDiscoveryFailed => 'Minttiehdotuksia ei voitu ladata.'; + + @override + String get noCashuMintSuggestions => + 'Saatavilla olevia minttiehdotuksia ei löytynyt.'; + + @override + String get noRatingsYet => 'Ei vielä arvioita'; + + @override + String cashuMintRating(String rating, int count) { + return '★ $rating · $count arvostelua'; + } + + @override + String get enterMintUrlManually => 'Syötä mintin URL manuaalisesti'; + @override String get cashuWalletTypeSubtitle => 'Käytä ecash-lompakkoa Cashu-mintin tukemana'; @@ -1161,57 +1281,63 @@ class AppLocalizationsFi extends AppLocalizations { String get bolt12Wallet => 'BOLT12-lompakko'; @override - String get bolt12WalletSubtitle => 'Uudelleenkäytettävä Lightning-tarjous'; + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; @override - String get bolt12PrivateOfferSubtitle => - 'Uudelleenkäytettävä yksityinen tarjous'; + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; @override - String get anyAmount => 'Mikä tahansa summa'; + String get anyAmount => 'Any amount'; @override - String get blindedRoute => 'Sokaisettu reitti'; + String get blindedRoute => 'Blinded'; @override String fromAmountSats(String amount) { - return 'Alkaen $amount sats'; + return 'From $amount sats'; } @override String fromAmountMsats(String amount) { - return 'Alkaen $amount msats'; + return 'From $amount msats'; } @override String fromCurrencyAmount(String amount, String currency) { - return 'Alkaen $amount $currency'; + return 'From $amount $currency'; } @override String bolt12Expires(String date) { - return 'Vanhenee $date'; + return 'Expires $date'; } @override String get bolt12WalletTypeTitle => 'BOLT12-tarjous'; + @override + String get bip353WalletTypeTitle => 'BIP353'; + + @override + String get lnurlProtocol => 'LNURL'; + @override String get bolt12WalletTypeSubtitle => - 'Vain vastaanottamiseen tarkoitettu lompakko, joka käyttää uudelleenkäytettävää tarjousta'; + 'Receive-only wallet using a reusable offer'; @override String get addBolt12WalletTitle => 'Lisää BOLT12-lompakko'; @override String get enterBolt12Input => - 'Syötä tai skannaa lno-tarjous, bitcoin:?lno=...-URI tai BIP353-osoite.'; + 'Syötä tai skannaa lno-tarjous, bitcoin:?lno=…-URI tai BIP353-osoite.'; @override String get bolt12Input => 'BOLT12-maksukohde'; @override - String get bolt12InputHint => 'lno1..., bitcoin:?lno=... tai user@domain.com'; + String get bolt12InputHint => + 'lno1…, bitcoin:?lno=… tai käyttäjä@verkkotunnus.com'; @override String get walletNameOptional => 'Lompakon nimi (valinnainen)'; @@ -1221,19 +1347,105 @@ class AppLocalizationsFi extends AppLocalizations { @override String get invalidBolt12QrCode => - 'QR-koodi ei ole BOLT12-, BIP321- tai BIP353-maksukohde.'; + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; @override String get pleaseEnterBolt12Input => 'Syötä BOLT12-tarjous tai BIP353-osoite.'; @override - String get bolt12WalletAdded => 'BOLT12-lompakko lisättiin onnistuneesti!'; + String get bolt12WalletAdded => 'BOLT12-lompakko lisätty!'; @override - String get bolt12OfferTitle => 'Vastaanota BOLT12:lla'; + String get bolt12OfferTitle => 'Receive with BOLT12'; @override String get bolt12OfferInstructions => - 'Jaa tämä uudelleenkäytettävä tarjous vastaanottaaksesi Lightning-maksun.'; + 'Share this reusable offer to receive a Lightning payment.'; + + @override + String get confirm => 'Vahvista'; + + @override + String get reviewWallet => 'Tarkista lompakko'; + + @override + String get confirmWalletTitle => 'Vahvista lompakko'; + + @override + String get confirmWalletDescription => + 'Tarkista nämä tiedot ennen lompakon lisäämistä.'; + + @override + String get walletDetailType => 'Lompakon tyyppi'; + + @override + String get walletDetailAddress => 'Osoite'; + + @override + String get walletDetailDomain => 'Verkkotunnus'; + + @override + String get walletDetailUrl => 'URL'; + + @override + String get walletDetailPublicKey => 'Julkinen avain'; + + @override + String get walletDetailRelay => 'Rele'; + + @override + String get walletDetailRelays => 'Releet'; + + @override + String get walletDetailSecret => 'Yhteyssalaisuus'; + + @override + String get walletSecretHidden => + 'Olemassa ja piilotettu turvallisuuden vuoksi'; + + @override + String get walletDetailDescription => 'Kuvaus'; + + @override + String get walletDetailDetails => 'Tiedot'; + + @override + String get walletDetailIssuer => 'Myöntäjä'; + + @override + String get walletDetailAmount => 'Summa'; + + @override + String get walletDetailCurrency => 'Valuutta'; + + @override + String get walletDetailExpiry => 'Vanhenee'; + + @override + String get walletDetailNodeId => 'Solmun tunnus'; + + @override + String get walletDetailOffer => 'BOLT12-tarjous'; + + @override + String get walletDetailVersion => 'Versio'; + + @override + String get walletDetailUnits => 'Tuetut yksiköt'; + + @override + String get walletDetailContact => 'Yhteystieto'; + + @override + String get walletDetailTerms => 'Käyttöehdot'; + + @override + String get walletDetailMessage => 'Viesti'; + + @override + String get walletDetailCommunityRating => 'Yhteisön arvio'; + + @override + String get walletDetailCommunityReviews => 'Viimeisimmät yhteisöarvostelut'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart b/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart index 7174097b9..1f5063dc0 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart @@ -8,6 +8,23 @@ import 'app_localizations.dart'; class AppLocalizationsFr extends AppLocalizations { AppLocalizationsFr([String locale = 'fr']) : super(locale); + @override + String get saveBackupToFile => 'Enregistrer la sauvegarde dans un fichier'; + + @override + String get backupSavedToFile => 'Sauvegarde enregistrée dans un fichier'; + + @override + String get restoreFromFile => 'Restaurer depuis un fichier'; + + @override + String get backupFileReadFailed => + 'Impossible de lire le fichier de sauvegarde sélectionné.'; + + @override + String get fetchingWalletConnectionInfo => + 'Récupération des informations de connexion du portefeuille…'; + @override String get createAccount => 'Créer votre compte'; @@ -773,26 +790,24 @@ class AppLocalizationsFr extends AppLocalizations { String get payInvoiceTitle => 'Payer la Facture'; @override - String get sendToWallet => 'Envoyer vers un portefeuille'; + String get sendToWallet => 'Send to Wallet'; @override - String get sendToWalletDescription => - 'Transférer vers un autre portefeuille compatible'; + String get sendToWalletDescription => 'Transfer to another compatible wallet'; @override - String get noCompatibleReceivingWallets => - 'Aucun portefeuille de réception compatible'; + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; @override String get noCompatibleReceivingWalletsDescription => - 'Ajoutez ou connectez un autre portefeuille capable de recevoir un paiement pris en charge par ce portefeuille.'; + 'Add or connect another wallet that can receive a payment supported by this wallet.'; @override - String get destinationWallet => 'Portefeuille de destination'; + String get destinationWallet => 'Destination wallet'; @override String walletTransferSubmitted(String walletName) { - return 'Paiement envoyé à $walletName'; + return 'Payment sent to $walletName'; } @override @@ -991,6 +1006,87 @@ class AppLocalizationsFr extends AppLocalizations { @override String get addWalletTitle => 'Ajouter un Portefeuille'; + @override + String get addWalletDescription => + 'Scannez un QR code de portefeuille compatible, collez ses informations ou connectez-vous via une application de portefeuille.'; + + @override + String get scanWalletQrCode => 'Scanner le QR code du portefeuille'; + + @override + String get connectWithWallet => 'Connecter un portefeuille'; + + @override + String get chooseWalletApp => 'Choisir une application de portefeuille'; + + @override + String get oneClickConnect => 'Connexion en 1 clic'; + + @override + String get chooseWallet => 'Choisir un portefeuille'; + + @override + String get albyWalletOption => 'Alby'; + + @override + String get albyCloudOption => 'Alby Cloud'; + + @override + String get coinosWalletOption => 'Coinos'; + + @override + String get manualNwcConnection => 'Connexion NWC manuelle'; + + @override + String walletConnectionFinishIn(String walletName) { + return 'Terminez la connexion dans $walletName'; + } + + @override + String walletConnectionConnecting(String walletName) { + return 'Connexion à $walletName…'; + } + + @override + String walletConnectionConnected(String walletName) { + return '$walletName connecté'; + } + + @override + String walletConnectionFailed(String walletName) { + return 'Impossible de connecter $walletName'; + } + + @override + String get retry => 'Réessayer'; + + @override + String get chooseAnotherWallet => 'Choisir un autre portefeuille'; + + @override + String get chooseWalletAppDescription => + 'Approuvez une connexion NWC dans un portefeuille installé'; + + @override + String get walletInput => 'Adresse ou connexion du portefeuille'; + + @override + String get walletInputHint => + 'NWC, adresse Lightning/BIP353, offre BOLT12/BIP321 ou URL HTTPS d\'un mint Cashu'; + + @override + String get unsupportedWalletInput => + 'Cette adresse ou connexion de portefeuille n\'est pas prise en charge.'; + + @override + String get detected => 'Détecté'; + + @override + String get lightningAddressInputType => 'Adresse Lightning ou BIP353'; + + @override + String get manualWalletSetup => 'Configurer manuellement'; + @override String get chooseWalletType => 'Choisir le type de portefeuille'; @@ -1011,6 +1107,31 @@ class AppLocalizationsFr extends AppLocalizations { @override String get cashuWalletTypeTitle => 'Cashu'; + @override + String get chooseCashuMint => 'Choisir un mint Cashu'; + + @override + String get cashuMintRatingsNotice => + 'Les notes de la communauté proviennent d\'avis Nostr signés. Une note élevée ne garantit pas la sécurité d\'un mint.'; + + @override + String get cashuMintDiscoveryFailed => + 'Impossible de charger les suggestions de mints.'; + + @override + String get noCashuMintSuggestions => 'Aucune suggestion de mint disponible.'; + + @override + String get noRatingsYet => 'Aucune note pour le moment'; + + @override + String cashuMintRating(String rating, int count) { + return '★ $rating · $count avis'; + } + + @override + String get enterMintUrlManually => 'Saisir l\'URL du mint manuellement'; + @override String get cashuWalletTypeSubtitle => 'Utiliser un portefeuille ecash adosse a une mint Cashu'; @@ -1164,78 +1285,171 @@ class AppLocalizationsFr extends AppLocalizations { String get bolt12Wallet => 'Portefeuille BOLT12'; @override - String get bolt12WalletSubtitle => 'Offre Lightning réutilisable'; + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; @override - String get bolt12PrivateOfferSubtitle => 'Offre privée réutilisable'; + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; @override - String get anyAmount => 'N\'importe quel montant'; + String get anyAmount => 'Any amount'; @override - String get blindedRoute => 'Route aveuglée'; + String get blindedRoute => 'Blinded'; @override String fromAmountSats(String amount) { - return 'À partir de $amount sats'; + return 'From $amount sats'; } @override String fromAmountMsats(String amount) { - return 'À partir de $amount msats'; + return 'From $amount msats'; } @override String fromCurrencyAmount(String amount, String currency) { - return 'À partir de $amount $currency'; + return 'From $amount $currency'; } @override String bolt12Expires(String date) { - return 'Expire le $date'; + return 'Expires $date'; } @override String get bolt12WalletTypeTitle => 'Offre BOLT12'; + @override + String get bip353WalletTypeTitle => 'BIP353'; + + @override + String get lnurlProtocol => 'LNURL'; + @override String get bolt12WalletTypeSubtitle => - 'Portefeuille de réception uniquement utilisant une offre réutilisable'; + 'Receive-only wallet using a reusable offer'; @override String get addBolt12WalletTitle => 'Ajouter un portefeuille BOLT12'; @override String get enterBolt12Input => - 'Saisissez ou scannez une offre lno, un URI bitcoin:?lno=... ou une adresse BIP353.'; + 'Saisissez ou scannez une offre lno, un URI bitcoin:?lno=… ou une adresse BIP353.'; @override - String get bolt12Input => 'Destination de paiement BOLT12'; + String get bolt12Input => 'Cible de paiement BOLT12'; @override - String get bolt12InputHint => 'lno1..., bitcoin:?lno=... ou user@domain.com'; + String get bolt12InputHint => + 'lno1…, bitcoin:?lno=… ou utilisateur@domaine.com'; @override String get walletNameOptional => 'Nom du portefeuille (facultatif)'; @override - String get scanBolt12QrCodeTitle => 'Scanner le code QR BOLT12'; + String get scanBolt12QrCodeTitle => 'Scanner le QR code BOLT12'; @override String get invalidBolt12QrCode => - 'Le code QR ne correspond pas à une destination de paiement BOLT12, BIP321 ou BIP353.'; + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; @override String get pleaseEnterBolt12Input => - 'Veuillez saisir une offre BOLT12 ou une adresse BIP353.'; + 'Saisissez une offre BOLT12 ou une adresse BIP353.'; @override - String get bolt12WalletAdded => 'Portefeuille BOLT12 ajouté avec succès !'; + String get bolt12WalletAdded => 'Portefeuille BOLT12 ajouté !'; @override - String get bolt12OfferTitle => 'Recevoir avec BOLT12'; + String get bolt12OfferTitle => 'Receive with BOLT12'; @override String get bolt12OfferInstructions => - 'Partagez cette offre réutilisable pour recevoir un paiement Lightning.'; + 'Share this reusable offer to receive a Lightning payment.'; + + @override + String get confirm => 'Confirmer'; + + @override + String get reviewWallet => 'Vérifier le portefeuille'; + + @override + String get confirmWalletTitle => 'Confirmer le portefeuille'; + + @override + String get confirmWalletDescription => + 'Vérifiez ces informations avant d\'ajouter ce portefeuille.'; + + @override + String get walletDetailType => 'Type de portefeuille'; + + @override + String get walletDetailAddress => 'Adresse'; + + @override + String get walletDetailDomain => 'Domaine'; + + @override + String get walletDetailUrl => 'URL'; + + @override + String get walletDetailPublicKey => 'Clé publique'; + + @override + String get walletDetailRelay => 'Relais'; + + @override + String get walletDetailRelays => 'Relais'; + + @override + String get walletDetailSecret => 'Secret de connexion'; + + @override + String get walletSecretHidden => + 'Présent et masqué pour des raisons de sécurité'; + + @override + String get walletDetailDescription => 'Description'; + + @override + String get walletDetailDetails => 'Détails'; + + @override + String get walletDetailIssuer => 'Émetteur'; + + @override + String get walletDetailAmount => 'Montant'; + + @override + String get walletDetailCurrency => 'Devise'; + + @override + String get walletDetailExpiry => 'Expiration'; + + @override + String get walletDetailNodeId => 'ID du nœud'; + + @override + String get walletDetailOffer => 'Offre BOLT12'; + + @override + String get walletDetailVersion => 'Version'; + + @override + String get walletDetailUnits => 'Unités prises en charge'; + + @override + String get walletDetailContact => 'Contact'; + + @override + String get walletDetailTerms => 'Conditions d\'utilisation'; + + @override + String get walletDetailMessage => 'Message'; + + @override + String get walletDetailCommunityRating => 'Note de la communauté'; + + @override + String get walletDetailCommunityReviews => 'Avis récents de la communauté'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_it.dart b/packages/ndk_flutter/lib/l10n/app_localizations_it.dart index cadef2105..ef898d806 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_it.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_it.dart @@ -8,6 +8,23 @@ import 'app_localizations.dart'; class AppLocalizationsIt extends AppLocalizations { AppLocalizationsIt([String locale = 'it']) : super(locale); + @override + String get saveBackupToFile => 'Salva backup su file'; + + @override + String get backupSavedToFile => 'Backup salvato su file'; + + @override + String get restoreFromFile => 'Ripristina da file'; + + @override + String get backupFileReadFailed => + 'Impossibile leggere il file di backup selezionato.'; + + @override + String get fetchingWalletConnectionInfo => + 'Recupero delle informazioni di connessione del portafoglio…'; + @override String get createAccount => 'Crea il tuo account'; @@ -991,6 +1008,87 @@ class AppLocalizationsIt extends AppLocalizations { @override String get addWalletTitle => 'Aggiungi Portafoglio'; + @override + String get addWalletDescription => + 'Scansiona un codice QR di un portafoglio supportato, incolla i dati o connettiti tramite un\'app portafoglio.'; + + @override + String get scanWalletQrCode => 'Scansiona QR del portafoglio'; + + @override + String get connectWithWallet => 'Connetti un portafoglio'; + + @override + String get chooseWalletApp => 'Scegli app portafoglio'; + + @override + String get oneClickConnect => 'Connessione in 1 clic'; + + @override + String get chooseWallet => 'Scegli portafoglio'; + + @override + String get albyWalletOption => 'Alby'; + + @override + String get albyCloudOption => 'Alby Cloud'; + + @override + String get coinosWalletOption => 'Coinos'; + + @override + String get manualNwcConnection => 'Connessione NWC manuale'; + + @override + String walletConnectionFinishIn(String walletName) { + return 'Completa la connessione in $walletName'; + } + + @override + String walletConnectionConnecting(String walletName) { + return 'Connessione a $walletName…'; + } + + @override + String walletConnectionConnected(String walletName) { + return '$walletName connesso'; + } + + @override + String walletConnectionFailed(String walletName) { + return 'Impossibile connettere $walletName'; + } + + @override + String get retry => 'Riprova'; + + @override + String get chooseAnotherWallet => 'Scegli un altro portafoglio'; + + @override + String get chooseWalletAppDescription => + 'Approva una connessione NWC in un portafoglio installato'; + + @override + String get walletInput => 'Indirizzo o connessione del portafoglio'; + + @override + String get walletInputHint => + 'NWC, indirizzo Lightning/BIP353, offerta BOLT12/BIP321 o URL HTTPS di un mint Cashu'; + + @override + String get unsupportedWalletInput => + 'Questo indirizzo o connessione del portafoglio non è supportato.'; + + @override + String get detected => 'Rilevato'; + + @override + String get lightningAddressInputType => 'Indirizzo Lightning o BIP353'; + + @override + String get manualWalletSetup => 'Configura manualmente'; + @override String get chooseWalletType => 'Scegli il tipo di portafoglio'; @@ -1010,6 +1108,32 @@ class AppLocalizationsIt extends AppLocalizations { @override String get cashuWalletTypeTitle => 'Cashu'; + @override + String get chooseCashuMint => 'Scegli mint Cashu'; + + @override + String get cashuMintRatingsNotice => + 'Le valutazioni della community provengono da recensioni Nostr firmate. Una valutazione alta non garantisce che un mint sia sicuro.'; + + @override + String get cashuMintDiscoveryFailed => + 'Impossibile caricare i suggerimenti dei mint.'; + + @override + String get noCashuMintSuggestions => + 'Nessun suggerimento di mint disponibile.'; + + @override + String get noRatingsYet => 'Ancora nessuna valutazione'; + + @override + String cashuMintRating(String rating, int count) { + return '★ $rating · $count recensioni'; + } + + @override + String get enterMintUrlManually => 'Inserisci manualmente l\'URL del mint'; + @override String get cashuWalletTypeSubtitle => 'Usa un portafoglio ecash basato su una mint Cashu'; @@ -1160,7 +1284,7 @@ class AppLocalizationsIt extends AppLocalizations { } @override - String get bolt12Wallet => 'BOLT12 Wallet'; + String get bolt12Wallet => 'Portafoglio BOLT12'; @override String get bolt12WalletSubtitle => 'Reusable Lightning offer'; @@ -1195,30 +1319,36 @@ class AppLocalizationsIt extends AppLocalizations { } @override - String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + String get bolt12WalletTypeTitle => 'Offerta BOLT12'; + + @override + String get bip353WalletTypeTitle => 'BIP353'; + + @override + String get lnurlProtocol => 'LNURL'; @override String get bolt12WalletTypeSubtitle => 'Receive-only wallet using a reusable offer'; @override - String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + String get addBolt12WalletTitle => 'Aggiungi portafoglio BOLT12'; @override String get enterBolt12Input => - 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + 'Inserisci o scansiona un\'offerta lno, un URI bitcoin:?lno=… o un indirizzo BIP353.'; @override - String get bolt12Input => 'BOLT12 payment target'; + String get bolt12Input => 'Destinazione di pagamento BOLT12'; @override - String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + String get bolt12InputHint => 'lno1…, bitcoin:?lno=… o utente@dominio.com'; @override - String get walletNameOptional => 'Wallet name (optional)'; + String get walletNameOptional => 'Nome del portafoglio (facoltativo)'; @override - String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + String get scanBolt12QrCodeTitle => 'Scansiona codice QR BOLT12'; @override String get invalidBolt12QrCode => @@ -1226,10 +1356,10 @@ class AppLocalizationsIt extends AppLocalizations { @override String get pleaseEnterBolt12Input => - 'Please enter a BOLT12 offer or BIP353 address.'; + 'Inserisci un\'offerta BOLT12 o un indirizzo BIP353.'; @override - String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + String get bolt12WalletAdded => 'Portafoglio BOLT12 aggiunto!'; @override String get bolt12OfferTitle => 'Receive with BOLT12'; @@ -1237,4 +1367,90 @@ class AppLocalizationsIt extends AppLocalizations { @override String get bolt12OfferInstructions => 'Share this reusable offer to receive a Lightning payment.'; + + @override + String get confirm => 'Conferma'; + + @override + String get reviewWallet => 'Controlla portafoglio'; + + @override + String get confirmWalletTitle => 'Conferma portafoglio'; + + @override + String get confirmWalletDescription => + 'Controlla questi dati prima di aggiungere il portafoglio.'; + + @override + String get walletDetailType => 'Tipo di portafoglio'; + + @override + String get walletDetailAddress => 'Indirizzo'; + + @override + String get walletDetailDomain => 'Dominio'; + + @override + String get walletDetailUrl => 'URL'; + + @override + String get walletDetailPublicKey => 'Chiave pubblica'; + + @override + String get walletDetailRelay => 'Relay'; + + @override + String get walletDetailRelays => 'Relay'; + + @override + String get walletDetailSecret => 'Segreto di connessione'; + + @override + String get walletSecretHidden => 'Presente e nascosto per sicurezza'; + + @override + String get walletDetailDescription => 'Descrizione'; + + @override + String get walletDetailDetails => 'Dettagli'; + + @override + String get walletDetailIssuer => 'Emittente'; + + @override + String get walletDetailAmount => 'Importo'; + + @override + String get walletDetailCurrency => 'Valuta'; + + @override + String get walletDetailExpiry => 'Scadenza'; + + @override + String get walletDetailNodeId => 'ID nodo'; + + @override + String get walletDetailOffer => 'Offerta BOLT12'; + + @override + String get walletDetailVersion => 'Versione'; + + @override + String get walletDetailUnits => 'Unità supportate'; + + @override + String get walletDetailContact => 'Contatto'; + + @override + String get walletDetailTerms => 'Termini di servizio'; + + @override + String get walletDetailMessage => 'Messaggio'; + + @override + String get walletDetailCommunityRating => 'Valutazione della community'; + + @override + String get walletDetailCommunityReviews => + 'Recensioni recenti della community'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart b/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart index 737f7765d..bbd4d98aa 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart @@ -8,6 +8,21 @@ import 'app_localizations.dart'; class AppLocalizationsJa extends AppLocalizations { AppLocalizationsJa([String locale = 'ja']) : super(locale); + @override + String get saveBackupToFile => 'バックアップをファイルに保存'; + + @override + String get backupSavedToFile => 'バックアップをファイルに保存しました'; + + @override + String get restoreFromFile => 'ファイルから復元'; + + @override + String get backupFileReadFailed => '選択したバックアップファイルを読み込めませんでした。'; + + @override + String get fetchingWalletConnectionInfo => 'ウォレットの接続情報を取得中…'; + @override String get createAccount => 'アカウントを作成'; @@ -979,6 +994,85 @@ class AppLocalizationsJa extends AppLocalizations { @override String get addWalletTitle => 'ウォレットを追加'; + @override + String get addWalletDescription => + '対応するウォレットのQRコードをスキャンするか、接続情報を貼り付けるか、ウォレットアプリから接続します。'; + + @override + String get scanWalletQrCode => 'ウォレットのQRコードをスキャン'; + + @override + String get connectWithWallet => 'ウォレットに接続'; + + @override + String get chooseWalletApp => 'ウォレットアプリを選択'; + + @override + String get oneClickConnect => '1クリック接続'; + + @override + String get chooseWallet => 'ウォレットを選択'; + + @override + String get albyWalletOption => 'Alby'; + + @override + String get albyCloudOption => 'Alby Cloud'; + + @override + String get coinosWalletOption => 'Coinos'; + + @override + String get manualNwcConnection => 'NWCを手動接続'; + + @override + String walletConnectionFinishIn(String walletName) { + return '$walletNameで接続を完了してください'; + } + + @override + String walletConnectionConnecting(String walletName) { + return '$walletNameに接続中…'; + } + + @override + String walletConnectionConnected(String walletName) { + return '$walletNameに接続しました'; + } + + @override + String walletConnectionFailed(String walletName) { + return '$walletNameに接続できませんでした'; + } + + @override + String get retry => '再試行'; + + @override + String get chooseAnotherWallet => '別のウォレットを選択'; + + @override + String get chooseWalletAppDescription => 'インストール済みウォレットでNWC接続を承認します'; + + @override + String get walletInput => 'ウォレットアドレスまたは接続情報'; + + @override + String get walletInputHint => + 'NWC、Lightning/BIP353アドレス、BOLT12/BIP321オファー、またはCashuミントのHTTPS URL'; + + @override + String get unsupportedWalletInput => '対応していないウォレットアドレスまたは接続情報です。'; + + @override + String get detected => '検出済み'; + + @override + String get lightningAddressInputType => 'LightningまたはBIP353アドレス'; + + @override + String get manualWalletSetup => '手動で設定'; + @override String get chooseWalletType => 'ウォレットタイプを選択'; @@ -997,6 +1091,30 @@ class AppLocalizationsJa extends AppLocalizations { @override String get cashuWalletTypeTitle => 'Cashu'; + @override + String get chooseCashuMint => 'Cashuミントを選択'; + + @override + String get cashuMintRatingsNotice => + 'コミュニティ評価は署名済みNostrレビューに基づきます。高評価でもミントの安全性は保証されません。'; + + @override + String get cashuMintDiscoveryFailed => 'ミント候補を読み込めませんでした。'; + + @override + String get noCashuMintSuggestions => '利用可能なミント候補が見つかりません。'; + + @override + String get noRatingsYet => 'まだ評価がありません'; + + @override + String cashuMintRating(String rating, int count) { + return '★ $rating · $count件のレビュー'; + } + + @override + String get enterMintUrlManually => 'ミントURLを手動入力'; + @override String get cashuWalletTypeSubtitle => 'Cashuミント対応のecashウォレットを使う'; @@ -1145,7 +1263,7 @@ class AppLocalizationsJa extends AppLocalizations { } @override - String get bolt12Wallet => 'BOLT12 Wallet'; + String get bolt12Wallet => 'BOLT12ウォレット'; @override String get bolt12WalletSubtitle => 'Reusable Lightning offer'; @@ -1180,41 +1298,46 @@ class AppLocalizationsJa extends AppLocalizations { } @override - String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + String get bolt12WalletTypeTitle => 'BOLT12オファー'; + + @override + String get bip353WalletTypeTitle => 'BIP353'; + + @override + String get lnurlProtocol => 'LNURL'; @override String get bolt12WalletTypeSubtitle => 'Receive-only wallet using a reusable offer'; @override - String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + String get addBolt12WalletTitle => 'BOLT12ウォレットを追加'; @override String get enterBolt12Input => - 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + 'lnoオファー、bitcoin:?lno=… URI、またはBIP353アドレスを入力またはスキャンしてください。'; @override - String get bolt12Input => 'BOLT12 payment target'; + String get bolt12Input => 'BOLT12支払い先'; @override - String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + String get bolt12InputHint => 'lno1…、bitcoin:?lno=…、またはuser@domain.com'; @override - String get walletNameOptional => 'Wallet name (optional)'; + String get walletNameOptional => 'ウォレット名(任意)'; @override - String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + String get scanBolt12QrCodeTitle => 'BOLT12 QRコードをスキャン'; @override String get invalidBolt12QrCode => 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; @override - String get pleaseEnterBolt12Input => - 'Please enter a BOLT12 offer or BIP353 address.'; + String get pleaseEnterBolt12Input => 'BOLT12オファーまたはBIP353アドレスを入力してください。'; @override - String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + String get bolt12WalletAdded => 'BOLT12ウォレットを追加しました!'; @override String get bolt12OfferTitle => 'Receive with BOLT12'; @@ -1222,4 +1345,88 @@ class AppLocalizationsJa extends AppLocalizations { @override String get bolt12OfferInstructions => 'Share this reusable offer to receive a Lightning payment.'; + + @override + String get confirm => '確認'; + + @override + String get reviewWallet => 'ウォレットを確認'; + + @override + String get confirmWalletTitle => 'ウォレットを確認'; + + @override + String get confirmWalletDescription => 'このウォレットを追加する前に詳細を確認してください。'; + + @override + String get walletDetailType => 'ウォレットの種類'; + + @override + String get walletDetailAddress => 'アドレス'; + + @override + String get walletDetailDomain => 'ドメイン'; + + @override + String get walletDetailUrl => 'URL'; + + @override + String get walletDetailPublicKey => '公開鍵'; + + @override + String get walletDetailRelay => 'リレー'; + + @override + String get walletDetailRelays => 'リレー'; + + @override + String get walletDetailSecret => '接続シークレット'; + + @override + String get walletSecretHidden => '存在します(安全のため非表示)'; + + @override + String get walletDetailDescription => '説明'; + + @override + String get walletDetailDetails => '詳細'; + + @override + String get walletDetailIssuer => '発行者'; + + @override + String get walletDetailAmount => '金額'; + + @override + String get walletDetailCurrency => '通貨'; + + @override + String get walletDetailExpiry => '有効期限'; + + @override + String get walletDetailNodeId => 'ノードID'; + + @override + String get walletDetailOffer => 'BOLT12オファー'; + + @override + String get walletDetailVersion => 'バージョン'; + + @override + String get walletDetailUnits => '対応単位'; + + @override + String get walletDetailContact => '連絡先'; + + @override + String get walletDetailTerms => '利用規約'; + + @override + String get walletDetailMessage => 'メッセージ'; + + @override + String get walletDetailCommunityRating => 'コミュニティ評価'; + + @override + String get walletDetailCommunityReviews => '最近のコミュニティレビュー'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart b/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart index 81d31fb62..96d1fb081 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart @@ -8,6 +8,23 @@ import 'app_localizations.dart'; class AppLocalizationsPl extends AppLocalizations { AppLocalizationsPl([String locale = 'pl']) : super(locale); + @override + String get saveBackupToFile => 'Zapisz kopię do pliku'; + + @override + String get backupSavedToFile => 'Kopia zapisana do pliku'; + + @override + String get restoreFromFile => 'Przywróć z pliku'; + + @override + String get backupFileReadFailed => + 'Nie udało się odczytać wybranego pliku kopii zapasowej.'; + + @override + String get fetchingWalletConnectionInfo => + 'Pobieranie danych połączenia portfela…'; + @override String get createAccount => 'Utwórz konto'; @@ -990,6 +1007,87 @@ class AppLocalizationsPl extends AppLocalizations { @override String get addWalletTitle => 'Dodaj portfel'; + @override + String get addWalletDescription => + 'Zeskanuj obsługiwany kod QR portfela, wklej dane lub połącz się przez aplikację portfela.'; + + @override + String get scanWalletQrCode => 'Skanuj kod QR portfela'; + + @override + String get connectWithWallet => 'Połącz z portfelem'; + + @override + String get chooseWalletApp => 'Wybierz aplikację portfela'; + + @override + String get oneClickConnect => 'Połącz jednym kliknięciem'; + + @override + String get chooseWallet => 'Wybierz portfel'; + + @override + String get albyWalletOption => 'Alby'; + + @override + String get albyCloudOption => 'Alby Cloud'; + + @override + String get coinosWalletOption => 'Coinos'; + + @override + String get manualNwcConnection => 'Ręczne połączenie NWC'; + + @override + String walletConnectionFinishIn(String walletName) { + return 'Dokończ połączenie w $walletName'; + } + + @override + String walletConnectionConnecting(String walletName) { + return 'Łączenie z $walletName…'; + } + + @override + String walletConnectionConnected(String walletName) { + return 'Połączono z $walletName'; + } + + @override + String walletConnectionFailed(String walletName) { + return 'Nie udało się połączyć z $walletName'; + } + + @override + String get retry => 'Spróbuj ponownie'; + + @override + String get chooseAnotherWallet => 'Wybierz inny portfel'; + + @override + String get chooseWalletAppDescription => + 'Zatwierdź połączenie NWC w zainstalowanym portfelu'; + + @override + String get walletInput => 'Adres lub połączenie portfela'; + + @override + String get walletInputHint => + 'NWC, adres Lightning/BIP353, oferta BOLT12/BIP321 lub adres HTTPS mintu Cashu'; + + @override + String get unsupportedWalletInput => + 'Ten adres lub typ połączenia portfela nie jest obsługiwany.'; + + @override + String get detected => 'Wykryto'; + + @override + String get lightningAddressInputType => 'Adres Lightning lub BIP353'; + + @override + String get manualWalletSetup => 'Skonfiguruj ręcznie'; + @override String get chooseWalletType => 'Wybierz typ portfela'; @@ -1009,6 +1107,32 @@ class AppLocalizationsPl extends AppLocalizations { @override String get cashuWalletTypeTitle => 'Cashu'; + @override + String get chooseCashuMint => 'Wybierz mint Cashu'; + + @override + String get cashuMintRatingsNotice => + 'Oceny społeczności pochodzą z podpisanych recenzji Nostr. Wysoka ocena nie gwarantuje bezpieczeństwa mintu.'; + + @override + String get cashuMintDiscoveryFailed => + 'Nie udało się wczytać propozycji mintów.'; + + @override + String get noCashuMintSuggestions => + 'Nie znaleziono dostępnych propozycji mintów.'; + + @override + String get noRatingsYet => 'Brak ocen'; + + @override + String cashuMintRating(String rating, int count) { + return '★ $rating · $count recenzji'; + } + + @override + String get enterMintUrlManually => 'Wprowadź ręcznie adres URL mintu'; + @override String get cashuWalletTypeSubtitle => 'Uzyj portfela ecash opartego na mennicy Cashu'; @@ -1158,7 +1282,7 @@ class AppLocalizationsPl extends AppLocalizations { } @override - String get bolt12Wallet => 'BOLT12 Wallet'; + String get bolt12Wallet => 'Portfel BOLT12'; @override String get bolt12WalletSubtitle => 'Reusable Lightning offer'; @@ -1193,30 +1317,37 @@ class AppLocalizationsPl extends AppLocalizations { } @override - String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + String get bolt12WalletTypeTitle => 'Oferta BOLT12'; + + @override + String get bip353WalletTypeTitle => 'BIP353'; + + @override + String get lnurlProtocol => 'LNURL'; @override String get bolt12WalletTypeSubtitle => 'Receive-only wallet using a reusable offer'; @override - String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + String get addBolt12WalletTitle => 'Dodaj portfel BOLT12'; @override String get enterBolt12Input => - 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + 'Wprowadź lub zeskanuj ofertę lno, URI bitcoin:?lno=… albo adres BIP353.'; @override - String get bolt12Input => 'BOLT12 payment target'; + String get bolt12Input => 'Cel płatności BOLT12'; @override - String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + String get bolt12InputHint => + 'lno1…, bitcoin:?lno=… lub użytkownik@domena.com'; @override - String get walletNameOptional => 'Wallet name (optional)'; + String get walletNameOptional => 'Nazwa portfela (opcjonalna)'; @override - String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + String get scanBolt12QrCodeTitle => 'Skanuj kod QR BOLT12'; @override String get invalidBolt12QrCode => @@ -1224,10 +1355,10 @@ class AppLocalizationsPl extends AppLocalizations { @override String get pleaseEnterBolt12Input => - 'Please enter a BOLT12 offer or BIP353 address.'; + 'Wprowadź ofertę BOLT12 lub adres BIP353.'; @override - String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + String get bolt12WalletAdded => 'Dodano portfel BOLT12!'; @override String get bolt12OfferTitle => 'Receive with BOLT12'; @@ -1235,4 +1366,89 @@ class AppLocalizationsPl extends AppLocalizations { @override String get bolt12OfferInstructions => 'Share this reusable offer to receive a Lightning payment.'; + + @override + String get confirm => 'Potwierdź'; + + @override + String get reviewWallet => 'Sprawdź portfel'; + + @override + String get confirmWalletTitle => 'Potwierdź portfel'; + + @override + String get confirmWalletDescription => + 'Sprawdź te dane przed dodaniem portfela.'; + + @override + String get walletDetailType => 'Typ portfela'; + + @override + String get walletDetailAddress => 'Adres'; + + @override + String get walletDetailDomain => 'Domena'; + + @override + String get walletDetailUrl => 'URL'; + + @override + String get walletDetailPublicKey => 'Klucz publiczny'; + + @override + String get walletDetailRelay => 'Przekaźnik'; + + @override + String get walletDetailRelays => 'Przekaźniki'; + + @override + String get walletDetailSecret => 'Sekret połączenia'; + + @override + String get walletSecretHidden => 'Obecny i ukryty ze względów bezpieczeństwa'; + + @override + String get walletDetailDescription => 'Opis'; + + @override + String get walletDetailDetails => 'Szczegóły'; + + @override + String get walletDetailIssuer => 'Wystawca'; + + @override + String get walletDetailAmount => 'Kwota'; + + @override + String get walletDetailCurrency => 'Waluta'; + + @override + String get walletDetailExpiry => 'Wygasa'; + + @override + String get walletDetailNodeId => 'ID węzła'; + + @override + String get walletDetailOffer => 'Oferta BOLT12'; + + @override + String get walletDetailVersion => 'Wersja'; + + @override + String get walletDetailUnits => 'Obsługiwane jednostki'; + + @override + String get walletDetailContact => 'Kontakt'; + + @override + String get walletDetailTerms => 'Warunki korzystania'; + + @override + String get walletDetailMessage => 'Wiadomość'; + + @override + String get walletDetailCommunityRating => 'Ocena społeczności'; + + @override + String get walletDetailCommunityReviews => 'Najnowsze recenzje społeczności'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart b/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart index 65dd1e198..22377c264 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart @@ -8,6 +8,23 @@ import 'app_localizations.dart'; class AppLocalizationsPt extends AppLocalizations { AppLocalizationsPt([String locale = 'pt']) : super(locale); + @override + String get saveBackupToFile => 'Guardar cópia num ficheiro'; + + @override + String get backupSavedToFile => 'Cópia guardada num ficheiro'; + + @override + String get restoreFromFile => 'Restaurar de um ficheiro'; + + @override + String get backupFileReadFailed => + 'Não foi possível ler o ficheiro de cópia selecionado.'; + + @override + String get fetchingWalletConnectionInfo => + 'A obter informações de ligação da carteira…'; + @override String get createAccount => 'Criar a sua conta'; @@ -993,6 +1010,87 @@ class AppLocalizationsPt extends AppLocalizations { @override String get addWalletTitle => 'Adicionar carteira'; + @override + String get addWalletDescription => + 'Digitalize o código QR de uma carteira compatível, cole os dados ou ligue através de uma aplicação de carteira.'; + + @override + String get scanWalletQrCode => 'Digitalizar QR da carteira'; + + @override + String get connectWithWallet => 'Ligar a uma carteira'; + + @override + String get chooseWalletApp => 'Escolher aplicação de carteira'; + + @override + String get oneClickConnect => 'Ligação com 1 clique'; + + @override + String get chooseWallet => 'Escolher carteira'; + + @override + String get albyWalletOption => 'Alby'; + + @override + String get albyCloudOption => 'Alby Cloud'; + + @override + String get coinosWalletOption => 'Coinos'; + + @override + String get manualNwcConnection => 'Ligação NWC manual'; + + @override + String walletConnectionFinishIn(String walletName) { + return 'Conclua a ligação em $walletName'; + } + + @override + String walletConnectionConnecting(String walletName) { + return 'A ligar a $walletName…'; + } + + @override + String walletConnectionConnected(String walletName) { + return '$walletName ligada'; + } + + @override + String walletConnectionFailed(String walletName) { + return 'Não foi possível ligar a $walletName'; + } + + @override + String get retry => 'Tentar novamente'; + + @override + String get chooseAnotherWallet => 'Escolher outra carteira'; + + @override + String get chooseWalletAppDescription => + 'Aprove uma ligação NWC numa carteira instalada'; + + @override + String get walletInput => 'Endereço ou ligação da carteira'; + + @override + String get walletInputHint => + 'NWC, endereço Lightning/BIP353, oferta BOLT12/BIP321 ou URL HTTPS de um mint Cashu'; + + @override + String get unsupportedWalletInput => + 'Este endereço ou ligação de carteira não é compatível.'; + + @override + String get detected => 'Detetado'; + + @override + String get lightningAddressInputType => 'Endereço Lightning ou BIP353'; + + @override + String get manualWalletSetup => 'Configurar manualmente'; + @override String get chooseWalletType => 'Escolha o tipo de carteira'; @@ -1012,6 +1110,32 @@ class AppLocalizationsPt extends AppLocalizations { @override String get cashuWalletTypeTitle => 'Cashu'; + @override + String get chooseCashuMint => 'Escolher mint Cashu'; + + @override + String get cashuMintRatingsNotice => + 'As avaliações da comunidade provêm de análises Nostr assinadas. Uma avaliação alta não garante que um mint seja seguro.'; + + @override + String get cashuMintDiscoveryFailed => + 'Não foi possível carregar sugestões de mints.'; + + @override + String get noCashuMintSuggestions => + 'Não foram encontradas sugestões de mints disponíveis.'; + + @override + String get noRatingsYet => 'Ainda sem avaliações'; + + @override + String cashuMintRating(String rating, int count) { + return '★ $rating · $count avaliações'; + } + + @override + String get enterMintUrlManually => 'Introduzir URL do mint manualmente'; + @override String get cashuWalletTypeSubtitle => 'Use uma carteira ecash suportada por uma mint Cashu'; @@ -1162,7 +1286,7 @@ class AppLocalizationsPt extends AppLocalizations { } @override - String get bolt12Wallet => 'BOLT12 Wallet'; + String get bolt12Wallet => 'Carteira BOLT12'; @override String get bolt12WalletSubtitle => 'Reusable Lightning offer'; @@ -1197,30 +1321,37 @@ class AppLocalizationsPt extends AppLocalizations { } @override - String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + String get bolt12WalletTypeTitle => 'Oferta BOLT12'; + + @override + String get bip353WalletTypeTitle => 'BIP353'; + + @override + String get lnurlProtocol => 'LNURL'; @override String get bolt12WalletTypeSubtitle => 'Receive-only wallet using a reusable offer'; @override - String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + String get addBolt12WalletTitle => 'Adicionar carteira BOLT12'; @override String get enterBolt12Input => - 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + 'Introduza ou digitalize uma oferta lno, um URI bitcoin:?lno=… ou um endereço BIP353.'; @override - String get bolt12Input => 'BOLT12 payment target'; + String get bolt12Input => 'Destino de pagamento BOLT12'; @override - String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + String get bolt12InputHint => + 'lno1…, bitcoin:?lno=… ou utilizador@dominio.com'; @override - String get walletNameOptional => 'Wallet name (optional)'; + String get walletNameOptional => 'Nome da carteira (opcional)'; @override - String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + String get scanBolt12QrCodeTitle => 'Digitalizar código QR BOLT12'; @override String get invalidBolt12QrCode => @@ -1228,10 +1359,10 @@ class AppLocalizationsPt extends AppLocalizations { @override String get pleaseEnterBolt12Input => - 'Please enter a BOLT12 offer or BIP353 address.'; + 'Introduza uma oferta BOLT12 ou um endereço BIP353.'; @override - String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + String get bolt12WalletAdded => 'Carteira BOLT12 adicionada!'; @override String get bolt12OfferTitle => 'Receive with BOLT12'; @@ -1239,12 +1370,115 @@ class AppLocalizationsPt extends AppLocalizations { @override String get bolt12OfferInstructions => 'Share this reusable offer to receive a Lightning payment.'; + + @override + String get confirm => 'Confirmar'; + + @override + String get reviewWallet => 'Rever carteira'; + + @override + String get confirmWalletTitle => 'Confirmar carteira'; + + @override + String get confirmWalletDescription => + 'Reveja estes dados antes de adicionar a carteira.'; + + @override + String get walletDetailType => 'Tipo de carteira'; + + @override + String get walletDetailAddress => 'Endereço'; + + @override + String get walletDetailDomain => 'Domínio'; + + @override + String get walletDetailUrl => 'URL'; + + @override + String get walletDetailPublicKey => 'Chave pública'; + + @override + String get walletDetailRelay => 'Relay'; + + @override + String get walletDetailRelays => 'Relays'; + + @override + String get walletDetailSecret => 'Segredo da ligação'; + + @override + String get walletSecretHidden => 'Presente e oculto por segurança'; + + @override + String get walletDetailDescription => 'Descrição'; + + @override + String get walletDetailDetails => 'Detalhes'; + + @override + String get walletDetailIssuer => 'Emissor'; + + @override + String get walletDetailAmount => 'Montante'; + + @override + String get walletDetailCurrency => 'Moeda'; + + @override + String get walletDetailExpiry => 'Expira'; + + @override + String get walletDetailNodeId => 'ID do nó'; + + @override + String get walletDetailOffer => 'Oferta BOLT12'; + + @override + String get walletDetailVersion => 'Versão'; + + @override + String get walletDetailUnits => 'Unidades suportadas'; + + @override + String get walletDetailContact => 'Contacto'; + + @override + String get walletDetailTerms => 'Termos de serviço'; + + @override + String get walletDetailMessage => 'Mensagem'; + + @override + String get walletDetailCommunityRating => 'Avaliação da comunidade'; + + @override + String get walletDetailCommunityReviews => + 'Avaliações recentes da comunidade'; } /// The translations for Portuguese, as used in Brazil (`pt_BR`). class AppLocalizationsPtBr extends AppLocalizationsPt { AppLocalizationsPtBr() : super('pt_BR'); + @override + String get saveBackupToFile => 'Salvar backup em arquivo'; + + @override + String get backupSavedToFile => 'Backup salvo em arquivo'; + + @override + String get restoreFromFile => 'Restaurar de arquivo'; + + @override + String get backupFileReadFailed => + 'Não foi possível ler o arquivo de backup selecionado.'; + + @override + String get fetchingWalletConnectionInfo => + 'Obtendo informações de conexão da carteira…'; + @override String get createAccount => 'Criar sua conta'; @@ -2208,6 +2442,87 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get addWalletTitle => 'Adicionar carteira'; + @override + String get addWalletDescription => + 'Escaneie o código QR de uma carteira compatível, cole os dados ou conecte por um aplicativo de carteira.'; + + @override + String get scanWalletQrCode => 'Escanear QR da carteira'; + + @override + String get connectWithWallet => 'Conectar com uma carteira'; + + @override + String get chooseWalletApp => 'Escolher aplicativo de carteira'; + + @override + String get oneClickConnect => 'Conexão com 1 clique'; + + @override + String get chooseWallet => 'Escolher carteira'; + + @override + String get albyWalletOption => 'Alby'; + + @override + String get albyCloudOption => 'Alby Cloud'; + + @override + String get coinosWalletOption => 'Coinos'; + + @override + String get manualNwcConnection => 'Conexão NWC manual'; + + @override + String walletConnectionFinishIn(String walletName) { + return 'Conclua a conexão em $walletName'; + } + + @override + String walletConnectionConnecting(String walletName) { + return 'Conectando a $walletName…'; + } + + @override + String walletConnectionConnected(String walletName) { + return '$walletName conectada'; + } + + @override + String walletConnectionFailed(String walletName) { + return 'Não foi possível conectar a $walletName'; + } + + @override + String get retry => 'Tentar novamente'; + + @override + String get chooseAnotherWallet => 'Escolher outra carteira'; + + @override + String get chooseWalletAppDescription => + 'Aprove uma conexão NWC em uma carteira instalada'; + + @override + String get walletInput => 'Endereço ou conexão da carteira'; + + @override + String get walletInputHint => + 'NWC, endereço Lightning/BIP353, oferta BOLT12/BIP321 ou URL HTTPS de um mint Cashu'; + + @override + String get unsupportedWalletInput => + 'Este endereço ou conexão de carteira não é compatível.'; + + @override + String get detected => 'Detectado'; + + @override + String get lightningAddressInputType => 'Endereço Lightning ou BIP353'; + + @override + String get manualWalletSetup => 'Configurar manualmente'; + @override String get chooseWalletType => 'Escolha o tipo de carteira'; @@ -2227,6 +2542,32 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get cashuWalletTypeTitle => 'Cashu'; + @override + String get chooseCashuMint => 'Escolher mint Cashu'; + + @override + String get cashuMintRatingsNotice => + 'As avaliações da comunidade vêm de análises Nostr assinadas. Uma avaliação alta não garante que um mint seja seguro.'; + + @override + String get cashuMintDiscoveryFailed => + 'Não foi possível carregar sugestões de mints.'; + + @override + String get noCashuMintSuggestions => + 'Nenhuma sugestão de mint disponível foi encontrada.'; + + @override + String get noRatingsYet => 'Ainda sem avaliações'; + + @override + String cashuMintRating(String rating, int count) { + return '★ $rating · $count avaliações'; + } + + @override + String get enterMintUrlManually => 'Inserir URL do mint manualmente'; + @override String get cashuWalletTypeSubtitle => 'Use uma carteira ecash com suporte de uma mint Cashu'; @@ -2333,4 +2674,128 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get budgetNever => 'Nunca'; + + @override + String get bolt12Wallet => 'Carteira BOLT12'; + + @override + String get bolt12WalletTypeTitle => 'Oferta BOLT12'; + + @override + String get bip353WalletTypeTitle => 'BIP353'; + + @override + String get lnurlProtocol => 'LNURL'; + + @override + String get addBolt12WalletTitle => 'Adicionar carteira BOLT12'; + + @override + String get enterBolt12Input => + 'Insira ou escaneie uma oferta lno, um URI bitcoin:?lno=… ou um endereço BIP353.'; + + @override + String get bolt12Input => 'Destino de pagamento BOLT12'; + + @override + String get bolt12InputHint => 'lno1…, bitcoin:?lno=… ou usuario@dominio.com'; + + @override + String get walletNameOptional => 'Nome da carteira (opcional)'; + + @override + String get scanBolt12QrCodeTitle => 'Escanear código QR BOLT12'; + + @override + String get pleaseEnterBolt12Input => + 'Insira uma oferta BOLT12 ou um endereço BIP353.'; + + @override + String get bolt12WalletAdded => 'Carteira BOLT12 adicionada!'; + + @override + String get confirm => 'Confirmar'; + + @override + String get reviewWallet => 'Revisar carteira'; + + @override + String get confirmWalletTitle => 'Confirmar carteira'; + + @override + String get confirmWalletDescription => + 'Revise estes dados antes de adicionar a carteira.'; + + @override + String get walletDetailType => 'Tipo de carteira'; + + @override + String get walletDetailAddress => 'Endereço'; + + @override + String get walletDetailDomain => 'Domínio'; + + @override + String get walletDetailUrl => 'URL'; + + @override + String get walletDetailPublicKey => 'Chave pública'; + + @override + String get walletDetailRelay => 'Relay'; + + @override + String get walletDetailRelays => 'Relays'; + + @override + String get walletDetailSecret => 'Segredo da conexão'; + + @override + String get walletSecretHidden => 'Presente e oculto por segurança'; + + @override + String get walletDetailDescription => 'Descrição'; + + @override + String get walletDetailDetails => 'Detalhes'; + + @override + String get walletDetailIssuer => 'Emissor'; + + @override + String get walletDetailAmount => 'Valor'; + + @override + String get walletDetailCurrency => 'Moeda'; + + @override + String get walletDetailExpiry => 'Expira'; + + @override + String get walletDetailNodeId => 'ID do nó'; + + @override + String get walletDetailOffer => 'Oferta BOLT12'; + + @override + String get walletDetailVersion => 'Versão'; + + @override + String get walletDetailUnits => 'Unidades compatíveis'; + + @override + String get walletDetailContact => 'Contato'; + + @override + String get walletDetailTerms => 'Termos de serviço'; + + @override + String get walletDetailMessage => 'Mensagem'; + + @override + String get walletDetailCommunityRating => 'Avaliação da comunidade'; + + @override + String get walletDetailCommunityReviews => + 'Avaliações recentes da comunidade'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart b/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart index 7d523643f..32c4f2121 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart @@ -8,6 +8,23 @@ import 'app_localizations.dart'; class AppLocalizationsRu extends AppLocalizations { AppLocalizationsRu([String locale = 'ru']) : super(locale); + @override + String get saveBackupToFile => 'Сохранить резервную копию в файл'; + + @override + String get backupSavedToFile => 'Резервная копия сохранена в файл'; + + @override + String get restoreFromFile => 'Восстановить из файла'; + + @override + String get backupFileReadFailed => + 'Не удалось прочитать выбранный файл резервной копии.'; + + @override + String get fetchingWalletConnectionInfo => + 'Получение данных подключения кошелька…'; + @override String get createAccount => 'Создать аккаунт'; @@ -987,6 +1004,87 @@ class AppLocalizationsRu extends AppLocalizations { @override String get addWalletTitle => 'Добавить Кошелёк'; + @override + String get addWalletDescription => + 'Отсканируйте поддерживаемый QR-код кошелька, вставьте данные или подключитесь через приложение кошелька.'; + + @override + String get scanWalletQrCode => 'Сканировать QR-код кошелька'; + + @override + String get connectWithWallet => 'Подключить кошелёк'; + + @override + String get chooseWalletApp => 'Выбрать приложение кошелька'; + + @override + String get oneClickConnect => 'Подключить в 1 клик'; + + @override + String get chooseWallet => 'Выбрать кошелёк'; + + @override + String get albyWalletOption => 'Alby'; + + @override + String get albyCloudOption => 'Alby Cloud'; + + @override + String get coinosWalletOption => 'Coinos'; + + @override + String get manualNwcConnection => 'Ручное подключение NWC'; + + @override + String walletConnectionFinishIn(String walletName) { + return 'Завершите подключение в $walletName'; + } + + @override + String walletConnectionConnecting(String walletName) { + return 'Подключение к $walletName…'; + } + + @override + String walletConnectionConnected(String walletName) { + return '$walletName подключён'; + } + + @override + String walletConnectionFailed(String walletName) { + return 'Не удалось подключить $walletName'; + } + + @override + String get retry => 'Повторить'; + + @override + String get chooseAnotherWallet => 'Выбрать другой кошелёк'; + + @override + String get chooseWalletAppDescription => + 'Подтвердите NWC-подключение в установленном кошельке'; + + @override + String get walletInput => 'Адрес или подключение кошелька'; + + @override + String get walletInputHint => + 'NWC, адрес Lightning/BIP353, предложение BOLT12/BIP321 или HTTPS-адрес минта Cashu'; + + @override + String get unsupportedWalletInput => + 'Этот адрес или подключение кошелька не поддерживается.'; + + @override + String get detected => 'Обнаружено'; + + @override + String get lightningAddressInputType => 'Адрес Lightning или BIP353'; + + @override + String get manualWalletSetup => 'Настроить вручную'; + @override String get chooseWalletType => 'Выберите тип кошелька'; @@ -1007,6 +1105,32 @@ class AppLocalizationsRu extends AppLocalizations { @override String get cashuWalletTypeTitle => 'Cashu'; + @override + String get chooseCashuMint => 'Выбрать минт Cashu'; + + @override + String get cashuMintRatingsNotice => + 'Оценки сообщества взяты из подписанных отзывов Nostr. Высокая оценка не гарантирует безопасность минта.'; + + @override + String get cashuMintDiscoveryFailed => + 'Не удалось загрузить предложения минтов.'; + + @override + String get noCashuMintSuggestions => + 'Доступные предложения минтов не найдены.'; + + @override + String get noRatingsYet => 'Оценок пока нет'; + + @override + String cashuMintRating(String rating, int count) { + return '★ $rating · отзывов: $count'; + } + + @override + String get enterMintUrlManually => 'Ввести URL минта вручную'; + @override String get cashuWalletTypeSubtitle => 'Использовать ecash-кошелек на базе монетного двора Cashu'; @@ -1157,7 +1281,7 @@ class AppLocalizationsRu extends AppLocalizations { } @override - String get bolt12Wallet => 'BOLT12 Wallet'; + String get bolt12Wallet => 'Кошелёк BOLT12'; @override String get bolt12WalletSubtitle => 'Reusable Lightning offer'; @@ -1192,30 +1316,37 @@ class AppLocalizationsRu extends AppLocalizations { } @override - String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + String get bolt12WalletTypeTitle => 'Предложение BOLT12'; + + @override + String get bip353WalletTypeTitle => 'BIP353'; + + @override + String get lnurlProtocol => 'LNURL'; @override String get bolt12WalletTypeSubtitle => 'Receive-only wallet using a reusable offer'; @override - String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + String get addBolt12WalletTitle => 'Добавить кошелёк BOLT12'; @override String get enterBolt12Input => - 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + 'Введите или отсканируйте предложение lno, URI bitcoin:?lno=… или адрес BIP353.'; @override - String get bolt12Input => 'BOLT12 payment target'; + String get bolt12Input => 'Цель платежа BOLT12'; @override - String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + String get bolt12InputHint => + 'lno1…, bitcoin:?lno=… или пользователь@домен.com'; @override - String get walletNameOptional => 'Wallet name (optional)'; + String get walletNameOptional => 'Название кошелька (необязательно)'; @override - String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + String get scanBolt12QrCodeTitle => 'Сканировать QR-код BOLT12'; @override String get invalidBolt12QrCode => @@ -1223,10 +1354,10 @@ class AppLocalizationsRu extends AppLocalizations { @override String get pleaseEnterBolt12Input => - 'Please enter a BOLT12 offer or BIP353 address.'; + 'Введите предложение BOLT12 или адрес BIP353.'; @override - String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + String get bolt12WalletAdded => 'Кошелёк BOLT12 успешно добавлен!'; @override String get bolt12OfferTitle => 'Receive with BOLT12'; @@ -1234,4 +1365,89 @@ class AppLocalizationsRu extends AppLocalizations { @override String get bolt12OfferInstructions => 'Share this reusable offer to receive a Lightning payment.'; + + @override + String get confirm => 'Подтвердить'; + + @override + String get reviewWallet => 'Проверить кошелёк'; + + @override + String get confirmWalletTitle => 'Подтвердить кошелёк'; + + @override + String get confirmWalletDescription => + 'Проверьте эти данные перед добавлением кошелька.'; + + @override + String get walletDetailType => 'Тип кошелька'; + + @override + String get walletDetailAddress => 'Адрес'; + + @override + String get walletDetailDomain => 'Домен'; + + @override + String get walletDetailUrl => 'URL'; + + @override + String get walletDetailPublicKey => 'Публичный ключ'; + + @override + String get walletDetailRelay => 'Ретранслятор'; + + @override + String get walletDetailRelays => 'Ретрансляторы'; + + @override + String get walletDetailSecret => 'Секрет подключения'; + + @override + String get walletSecretHidden => 'Присутствует и скрыт для безопасности'; + + @override + String get walletDetailDescription => 'Описание'; + + @override + String get walletDetailDetails => 'Сведения'; + + @override + String get walletDetailIssuer => 'Эмитент'; + + @override + String get walletDetailAmount => 'Сумма'; + + @override + String get walletDetailCurrency => 'Валюта'; + + @override + String get walletDetailExpiry => 'Истекает'; + + @override + String get walletDetailNodeId => 'ID узла'; + + @override + String get walletDetailOffer => 'Предложение BOLT12'; + + @override + String get walletDetailVersion => 'Версия'; + + @override + String get walletDetailUnits => 'Поддерживаемые единицы'; + + @override + String get walletDetailContact => 'Контакт'; + + @override + String get walletDetailTerms => 'Условия использования'; + + @override + String get walletDetailMessage => 'Сообщение'; + + @override + String get walletDetailCommunityRating => 'Оценка сообщества'; + + @override + String get walletDetailCommunityReviews => 'Недавние отзывы сообщества'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart b/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart index 6f6ea6c4e..9becf3461 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart @@ -8,6 +8,23 @@ import 'app_localizations.dart'; class AppLocalizationsSk extends AppLocalizations { AppLocalizationsSk([String locale = 'sk']) : super(locale); + @override + String get saveBackupToFile => 'Uložiť zálohu do súboru'; + + @override + String get backupSavedToFile => 'Záloha bola uložená do súboru'; + + @override + String get restoreFromFile => 'Obnoviť zo súboru'; + + @override + String get backupFileReadFailed => + 'Vybraný súbor zálohy sa nepodarilo prečítať.'; + + @override + String get fetchingWalletConnectionInfo => + 'Načítavajú sa údaje pripojenia peňaženky…'; + @override String get createAccount => 'Vytvorte si účet'; @@ -987,6 +1004,87 @@ class AppLocalizationsSk extends AppLocalizations { @override String get addWalletTitle => 'Pridať peňaženku'; + @override + String get addWalletDescription => + 'Naskenujte podporovaný QR kód peňaženky, vložte údaje alebo sa pripojte cez aplikáciu peňaženky.'; + + @override + String get scanWalletQrCode => 'Naskenovať QR kód peňaženky'; + + @override + String get connectWithWallet => 'Pripojiť peňaženku'; + + @override + String get chooseWalletApp => 'Vybrať aplikáciu peňaženky'; + + @override + String get oneClickConnect => 'Pripojiť jedným kliknutím'; + + @override + String get chooseWallet => 'Vybrať peňaženku'; + + @override + String get albyWalletOption => 'Alby'; + + @override + String get albyCloudOption => 'Alby Cloud'; + + @override + String get coinosWalletOption => 'Coinos'; + + @override + String get manualNwcConnection => 'Ručné pripojenie NWC'; + + @override + String walletConnectionFinishIn(String walletName) { + return 'Dokončite pripojenie v $walletName'; + } + + @override + String walletConnectionConnecting(String walletName) { + return 'Pripája sa $walletName…'; + } + + @override + String walletConnectionConnected(String walletName) { + return '$walletName pripojená'; + } + + @override + String walletConnectionFailed(String walletName) { + return 'Nepodarilo sa pripojiť $walletName'; + } + + @override + String get retry => 'Skúsiť znova'; + + @override + String get chooseAnotherWallet => 'Vybrať inú peňaženku'; + + @override + String get chooseWalletAppDescription => + 'Schváľte pripojenie NWC v nainštalovanej peňaženke'; + + @override + String get walletInput => 'Adresa alebo pripojenie peňaženky'; + + @override + String get walletInputHint => + 'NWC, adresa Lightning/BIP353, ponuka BOLT12/BIP321 alebo HTTPS URL Cashu mintu'; + + @override + String get unsupportedWalletInput => + 'Táto adresa alebo pripojenie peňaženky nie je podporované.'; + + @override + String get detected => 'Rozpoznané'; + + @override + String get lightningAddressInputType => 'Adresa Lightning alebo BIP353'; + + @override + String get manualWalletSetup => 'Nastaviť ručne'; + @override String get chooseWalletType => 'Vyberte typ peňaženky'; @@ -1006,6 +1104,31 @@ class AppLocalizationsSk extends AppLocalizations { @override String get cashuWalletTypeTitle => 'Cashu'; + @override + String get chooseCashuMint => 'Vybrať Cashu mint'; + + @override + String get cashuMintRatingsNotice => + 'Hodnotenia komunity pochádzajú z podpísaných recenzií Nostr. Vysoké hodnotenie nezaručuje bezpečnosť mintu.'; + + @override + String get cashuMintDiscoveryFailed => 'Návrhy mintov sa nepodarilo načítať.'; + + @override + String get noCashuMintSuggestions => + 'Nenašli sa žiadne dostupné návrhy mintov.'; + + @override + String get noRatingsYet => 'Zatiaľ bez hodnotení'; + + @override + String cashuMintRating(String rating, int count) { + return '★ $rating · $count recenzií'; + } + + @override + String get enterMintUrlManually => 'Zadať URL mintu ručne'; + @override String get cashuWalletTypeSubtitle => 'Použiť ecash peňaženku podporovanú Cashu mintom'; @@ -1156,7 +1279,7 @@ class AppLocalizationsSk extends AppLocalizations { } @override - String get bolt12Wallet => 'BOLT12 Wallet'; + String get bolt12Wallet => 'Peňaženka BOLT12'; @override String get bolt12WalletSubtitle => 'Reusable Lightning offer'; @@ -1191,30 +1314,37 @@ class AppLocalizationsSk extends AppLocalizations { } @override - String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + String get bolt12WalletTypeTitle => 'Ponuka BOLT12'; + + @override + String get bip353WalletTypeTitle => 'BIP353'; + + @override + String get lnurlProtocol => 'LNURL'; @override String get bolt12WalletTypeSubtitle => 'Receive-only wallet using a reusable offer'; @override - String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + String get addBolt12WalletTitle => 'Pridať peňaženku BOLT12'; @override String get enterBolt12Input => - 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + 'Zadajte alebo naskenujte ponuku lno, URI bitcoin:?lno=… alebo adresu BIP353.'; @override - String get bolt12Input => 'BOLT12 payment target'; + String get bolt12Input => 'Platobný cieľ BOLT12'; @override - String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + String get bolt12InputHint => + 'lno1…, bitcoin:?lno=… alebo používateľ@doména.com'; @override - String get walletNameOptional => 'Wallet name (optional)'; + String get walletNameOptional => 'Názov peňaženky (voliteľné)'; @override - String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + String get scanBolt12QrCodeTitle => 'Naskenovať QR kód BOLT12'; @override String get invalidBolt12QrCode => @@ -1222,10 +1352,10 @@ class AppLocalizationsSk extends AppLocalizations { @override String get pleaseEnterBolt12Input => - 'Please enter a BOLT12 offer or BIP353 address.'; + 'Zadajte ponuku BOLT12 alebo adresu BIP353.'; @override - String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + String get bolt12WalletAdded => 'Peňaženka BOLT12 bola pridaná!'; @override String get bolt12OfferTitle => 'Receive with BOLT12'; @@ -1233,4 +1363,89 @@ class AppLocalizationsSk extends AppLocalizations { @override String get bolt12OfferInstructions => 'Share this reusable offer to receive a Lightning payment.'; + + @override + String get confirm => 'Potvrdiť'; + + @override + String get reviewWallet => 'Skontrolovať peňaženku'; + + @override + String get confirmWalletTitle => 'Potvrdiť peňaženku'; + + @override + String get confirmWalletDescription => + 'Pred pridaním peňaženky skontrolujte tieto údaje.'; + + @override + String get walletDetailType => 'Typ peňaženky'; + + @override + String get walletDetailAddress => 'Adresa'; + + @override + String get walletDetailDomain => 'Doména'; + + @override + String get walletDetailUrl => 'URL'; + + @override + String get walletDetailPublicKey => 'Verejný kľúč'; + + @override + String get walletDetailRelay => 'Relay'; + + @override + String get walletDetailRelays => 'Relaye'; + + @override + String get walletDetailSecret => 'Tajný údaj pripojenia'; + + @override + String get walletSecretHidden => 'Prítomný a z bezpečnostných dôvodov skrytý'; + + @override + String get walletDetailDescription => 'Popis'; + + @override + String get walletDetailDetails => 'Podrobnosti'; + + @override + String get walletDetailIssuer => 'Vydavateľ'; + + @override + String get walletDetailAmount => 'Suma'; + + @override + String get walletDetailCurrency => 'Mena'; + + @override + String get walletDetailExpiry => 'Platnosť vyprší'; + + @override + String get walletDetailNodeId => 'ID uzla'; + + @override + String get walletDetailOffer => 'Ponuka BOLT12'; + + @override + String get walletDetailVersion => 'Verzia'; + + @override + String get walletDetailUnits => 'Podporované jednotky'; + + @override + String get walletDetailContact => 'Kontakt'; + + @override + String get walletDetailTerms => 'Podmienky služby'; + + @override + String get walletDetailMessage => 'Správa'; + + @override + String get walletDetailCommunityRating => 'Hodnotenie komunity'; + + @override + String get walletDetailCommunityReviews => 'Najnovšie recenzie komunity'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart b/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart index 0dc6fb56b..cb7661c76 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart @@ -8,6 +8,21 @@ import 'app_localizations.dart'; class AppLocalizationsZh extends AppLocalizations { AppLocalizationsZh([String locale = 'zh']) : super(locale); + @override + String get saveBackupToFile => '将备份保存到文件'; + + @override + String get backupSavedToFile => '备份已保存到文件'; + + @override + String get restoreFromFile => '从文件恢复'; + + @override + String get backupFileReadFailed => '无法读取所选备份文件。'; + + @override + String get fetchingWalletConnectionInfo => '正在获取钱包连接信息…'; + @override String get createAccount => '创建账户'; @@ -978,6 +993,84 @@ class AppLocalizationsZh extends AppLocalizations { @override String get addWalletTitle => '添加钱包'; + @override + String get addWalletDescription => '扫描受支持的钱包二维码、粘贴连接信息,或通过钱包应用连接。'; + + @override + String get scanWalletQrCode => '扫描钱包二维码'; + + @override + String get connectWithWallet => '连接钱包'; + + @override + String get chooseWalletApp => '选择钱包应用'; + + @override + String get oneClickConnect => '一键连接'; + + @override + String get chooseWallet => '选择钱包'; + + @override + String get albyWalletOption => 'Alby'; + + @override + String get albyCloudOption => 'Alby Cloud'; + + @override + String get coinosWalletOption => 'Coinos'; + + @override + String get manualNwcConnection => '手动连接 NWC'; + + @override + String walletConnectionFinishIn(String walletName) { + return '请在 $walletName 中完成连接'; + } + + @override + String walletConnectionConnecting(String walletName) { + return '正在连接 $walletName…'; + } + + @override + String walletConnectionConnected(String walletName) { + return '已连接 $walletName'; + } + + @override + String walletConnectionFailed(String walletName) { + return '无法连接 $walletName'; + } + + @override + String get retry => '重试'; + + @override + String get chooseAnotherWallet => '选择其他钱包'; + + @override + String get chooseWalletAppDescription => '在已安装的钱包中批准 NWC 连接'; + + @override + String get walletInput => '钱包地址或连接信息'; + + @override + String get walletInputHint => + 'NWC、Lightning/BIP353 地址、BOLT12/BIP321 报价或 Cashu 铸币厂 HTTPS URL'; + + @override + String get unsupportedWalletInput => '不支持此钱包地址或连接信息。'; + + @override + String get detected => '已检测'; + + @override + String get lightningAddressInputType => 'Lightning 或 BIP353 地址'; + + @override + String get manualWalletSetup => '手动设置'; + @override String get chooseWalletType => '选择钱包类型'; @@ -996,6 +1089,29 @@ class AppLocalizationsZh extends AppLocalizations { @override String get cashuWalletTypeTitle => 'Cashu'; + @override + String get chooseCashuMint => '选择 Cashu 铸币厂'; + + @override + String get cashuMintRatingsNotice => '社区评分来自已签名的 Nostr 评论。高评分并不能保证铸币厂安全。'; + + @override + String get cashuMintDiscoveryFailed => '无法加载铸币厂建议。'; + + @override + String get noCashuMintSuggestions => '未找到可用的铸币厂建议。'; + + @override + String get noRatingsYet => '暂无评分'; + + @override + String cashuMintRating(String rating, int count) { + return '★ $rating · $count 条评论'; + } + + @override + String get enterMintUrlManually => '手动输入铸币厂 URL'; + @override String get cashuWalletTypeSubtitle => '使用由 Cashu mint 支持的 ecash 钱包'; @@ -1144,7 +1260,7 @@ class AppLocalizationsZh extends AppLocalizations { } @override - String get bolt12Wallet => 'BOLT12 Wallet'; + String get bolt12Wallet => 'BOLT12 钱包'; @override String get bolt12WalletSubtitle => 'Reusable Lightning offer'; @@ -1179,41 +1295,45 @@ class AppLocalizationsZh extends AppLocalizations { } @override - String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + String get bolt12WalletTypeTitle => 'BOLT12 报价'; + + @override + String get bip353WalletTypeTitle => 'BIP353'; + + @override + String get lnurlProtocol => 'LNURL'; @override String get bolt12WalletTypeSubtitle => 'Receive-only wallet using a reusable offer'; @override - String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + String get addBolt12WalletTitle => '添加 BOLT12 钱包'; @override - String get enterBolt12Input => - 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + String get enterBolt12Input => '输入或扫描 lno 报价、bitcoin:?lno=… URI 或 BIP353 地址。'; @override - String get bolt12Input => 'BOLT12 payment target'; + String get bolt12Input => 'BOLT12 支付目标'; @override - String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + String get bolt12InputHint => 'lno1…、bitcoin:?lno=… 或 user@domain.com'; @override - String get walletNameOptional => 'Wallet name (optional)'; + String get walletNameOptional => '钱包名称(可选)'; @override - String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + String get scanBolt12QrCodeTitle => '扫描 BOLT12 二维码'; @override String get invalidBolt12QrCode => 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; @override - String get pleaseEnterBolt12Input => - 'Please enter a BOLT12 offer or BIP353 address.'; + String get pleaseEnterBolt12Input => '请输入 BOLT12 报价或 BIP353 地址。'; @override - String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + String get bolt12WalletAdded => 'BOLT12 钱包添加成功!'; @override String get bolt12OfferTitle => 'Receive with BOLT12'; @@ -1221,4 +1341,88 @@ class AppLocalizationsZh extends AppLocalizations { @override String get bolt12OfferInstructions => 'Share this reusable offer to receive a Lightning payment.'; + + @override + String get confirm => '确认'; + + @override + String get reviewWallet => '检查钱包'; + + @override + String get confirmWalletTitle => '确认钱包'; + + @override + String get confirmWalletDescription => '添加钱包前请检查这些信息。'; + + @override + String get walletDetailType => '钱包类型'; + + @override + String get walletDetailAddress => '地址'; + + @override + String get walletDetailDomain => '域名'; + + @override + String get walletDetailUrl => 'URL'; + + @override + String get walletDetailPublicKey => '公钥'; + + @override + String get walletDetailRelay => '中继'; + + @override + String get walletDetailRelays => '中继'; + + @override + String get walletDetailSecret => '连接密钥'; + + @override + String get walletSecretHidden => '已提供并因安全原因隐藏'; + + @override + String get walletDetailDescription => '说明'; + + @override + String get walletDetailDetails => '详情'; + + @override + String get walletDetailIssuer => '发行方'; + + @override + String get walletDetailAmount => '金额'; + + @override + String get walletDetailCurrency => '货币'; + + @override + String get walletDetailExpiry => '到期时间'; + + @override + String get walletDetailNodeId => '节点 ID'; + + @override + String get walletDetailOffer => 'BOLT12 报价'; + + @override + String get walletDetailVersion => '版本'; + + @override + String get walletDetailUnits => '支持的单位'; + + @override + String get walletDetailContact => '联系方式'; + + @override + String get walletDetailTerms => '服务条款'; + + @override + String get walletDetailMessage => '消息'; + + @override + String get walletDetailCommunityRating => '社区评分'; + + @override + String get walletDetailCommunityReviews => '近期社区评论'; } diff --git a/packages/ndk_flutter/lib/l10n/app_pl.arb b/packages/ndk_flutter/lib/l10n/app_pl.arb index 240bf2f96..0148f0ae6 100644 --- a/packages/ndk_flutter/lib/l10n/app_pl.arb +++ b/packages/ndk_flutter/lib/l10n/app_pl.arb @@ -1,4 +1,79 @@ { + "saveBackupToFile": "Zapisz kopię do pliku", + "backupSavedToFile": "Kopia zapisana do pliku", + "restoreFromFile": "Przywróć z pliku", + "backupFileReadFailed": "Nie udało się odczytać wybranego pliku kopii zapasowej.", + "addBolt12WalletTitle": "Dodaj portfel BOLT12", + "bolt12Input": "Cel płatności BOLT12", + "bolt12InputHint": "lno1…, bitcoin:?lno=… lub użytkownik@domena.com", + "bolt12Wallet": "Portfel BOLT12", + "bolt12WalletAdded": "Dodano portfel BOLT12!", + "bolt12WalletTypeTitle": "Oferta BOLT12", + "enterBolt12Input": "Wprowadź lub zeskanuj ofertę lno, URI bitcoin:?lno=… albo adres BIP353.", + "pleaseEnterBolt12Input": "Wprowadź ofertę BOLT12 lub adres BIP353.", + "scanBolt12QrCodeTitle": "Skanuj kod QR BOLT12", + "walletNameOptional": "Nazwa portfela (opcjonalna)", + "fetchingWalletConnectionInfo": "Pobieranie danych połączenia portfela…", + "addWalletDescription": "Zeskanuj obsługiwany kod QR portfela, wklej dane lub połącz się przez aplikację portfela.", + "scanWalletQrCode": "Skanuj kod QR portfela", + "connectWithWallet": "Połącz z portfelem", + "chooseWalletApp": "Wybierz aplikację portfela", + "oneClickConnect": "Połącz jednym kliknięciem", + "chooseWallet": "Wybierz portfel", + "albyWalletOption": "Alby", + "albyCloudOption": "Alby Cloud", + "coinosWalletOption": "Coinos", + "manualNwcConnection": "Ręczne połączenie NWC", + "walletConnectionFinishIn": "Dokończ połączenie w {walletName}", + "walletConnectionConnecting": "Łączenie z {walletName}…", + "walletConnectionConnected": "Połączono z {walletName}", + "walletConnectionFailed": "Nie udało się połączyć z {walletName}", + "retry": "Spróbuj ponownie", + "chooseAnotherWallet": "Wybierz inny portfel", + "chooseWalletAppDescription": "Zatwierdź połączenie NWC w zainstalowanym portfelu", + "walletInput": "Adres lub połączenie portfela", + "walletInputHint": "NWC, adres Lightning/BIP353, oferta BOLT12/BIP321 lub adres HTTPS mintu Cashu", + "unsupportedWalletInput": "Ten adres lub typ połączenia portfela nie jest obsługiwany.", + "detected": "Wykryto", + "lightningAddressInputType": "Adres Lightning lub BIP353", + "manualWalletSetup": "Skonfiguruj ręcznie", + "chooseCashuMint": "Wybierz mint Cashu", + "cashuMintRatingsNotice": "Oceny społeczności pochodzą z podpisanych recenzji Nostr. Wysoka ocena nie gwarantuje bezpieczeństwa mintu.", + "cashuMintDiscoveryFailed": "Nie udało się wczytać propozycji mintów.", + "noCashuMintSuggestions": "Nie znaleziono dostępnych propozycji mintów.", + "noRatingsYet": "Brak ocen", + "cashuMintRating": "★ {rating} · {count} recenzji", + "enterMintUrlManually": "Wprowadź ręcznie adres URL mintu", + "bip353WalletTypeTitle": "BIP353", + "lnurlProtocol": "LNURL", + "confirm": "Potwierdź", + "reviewWallet": "Sprawdź portfel", + "confirmWalletTitle": "Potwierdź portfel", + "confirmWalletDescription": "Sprawdź te dane przed dodaniem portfela.", + "walletDetailType": "Typ portfela", + "walletDetailAddress": "Adres", + "walletDetailDomain": "Domena", + "walletDetailUrl": "URL", + "walletDetailPublicKey": "Klucz publiczny", + "walletDetailRelay": "Przekaźnik", + "walletDetailRelays": "Przekaźniki", + "walletDetailSecret": "Sekret połączenia", + "walletSecretHidden": "Obecny i ukryty ze względów bezpieczeństwa", + "walletDetailDescription": "Opis", + "walletDetailDetails": "Szczegóły", + "walletDetailIssuer": "Wystawca", + "walletDetailAmount": "Kwota", + "walletDetailCurrency": "Waluta", + "walletDetailExpiry": "Wygasa", + "walletDetailNodeId": "ID węzła", + "walletDetailOffer": "Oferta BOLT12", + "walletDetailVersion": "Wersja", + "walletDetailUnits": "Obsługiwane jednostki", + "walletDetailContact": "Kontakt", + "walletDetailTerms": "Warunki korzystania", + "walletDetailMessage": "Wiadomość", + "walletDetailCommunityRating": "Ocena społeczności", + "walletDetailCommunityReviews": "Najnowsze recenzje społeczności", "@@locale": "pl", "createAccount": "Utwórz konto", "newHere": "Jesteś tu nowy?", diff --git a/packages/ndk_flutter/lib/l10n/app_pt.arb b/packages/ndk_flutter/lib/l10n/app_pt.arb index 478efb64c..9ab19a72d 100644 --- a/packages/ndk_flutter/lib/l10n/app_pt.arb +++ b/packages/ndk_flutter/lib/l10n/app_pt.arb @@ -1,4 +1,79 @@ { + "saveBackupToFile": "Guardar cópia num ficheiro", + "backupSavedToFile": "Cópia guardada num ficheiro", + "restoreFromFile": "Restaurar de um ficheiro", + "backupFileReadFailed": "Não foi possível ler o ficheiro de cópia selecionado.", + "addBolt12WalletTitle": "Adicionar carteira BOLT12", + "bolt12Input": "Destino de pagamento BOLT12", + "bolt12InputHint": "lno1…, bitcoin:?lno=… ou utilizador@dominio.com", + "bolt12Wallet": "Carteira BOLT12", + "bolt12WalletAdded": "Carteira BOLT12 adicionada!", + "bolt12WalletTypeTitle": "Oferta BOLT12", + "enterBolt12Input": "Introduza ou digitalize uma oferta lno, um URI bitcoin:?lno=… ou um endereço BIP353.", + "pleaseEnterBolt12Input": "Introduza uma oferta BOLT12 ou um endereço BIP353.", + "scanBolt12QrCodeTitle": "Digitalizar código QR BOLT12", + "walletNameOptional": "Nome da carteira (opcional)", + "fetchingWalletConnectionInfo": "A obter informações de ligação da carteira…", + "addWalletDescription": "Digitalize o código QR de uma carteira compatível, cole os dados ou ligue através de uma aplicação de carteira.", + "scanWalletQrCode": "Digitalizar QR da carteira", + "connectWithWallet": "Ligar a uma carteira", + "chooseWalletApp": "Escolher aplicação de carteira", + "oneClickConnect": "Ligação com 1 clique", + "chooseWallet": "Escolher carteira", + "albyWalletOption": "Alby", + "albyCloudOption": "Alby Cloud", + "coinosWalletOption": "Coinos", + "manualNwcConnection": "Ligação NWC manual", + "walletConnectionFinishIn": "Conclua a ligação em {walletName}", + "walletConnectionConnecting": "A ligar a {walletName}…", + "walletConnectionConnected": "{walletName} ligada", + "walletConnectionFailed": "Não foi possível ligar a {walletName}", + "retry": "Tentar novamente", + "chooseAnotherWallet": "Escolher outra carteira", + "chooseWalletAppDescription": "Aprove uma ligação NWC numa carteira instalada", + "walletInput": "Endereço ou ligação da carteira", + "walletInputHint": "NWC, endereço Lightning/BIP353, oferta BOLT12/BIP321 ou URL HTTPS de um mint Cashu", + "unsupportedWalletInput": "Este endereço ou ligação de carteira não é compatível.", + "detected": "Detetado", + "lightningAddressInputType": "Endereço Lightning ou BIP353", + "manualWalletSetup": "Configurar manualmente", + "chooseCashuMint": "Escolher mint Cashu", + "cashuMintRatingsNotice": "As avaliações da comunidade provêm de análises Nostr assinadas. Uma avaliação alta não garante que um mint seja seguro.", + "cashuMintDiscoveryFailed": "Não foi possível carregar sugestões de mints.", + "noCashuMintSuggestions": "Não foram encontradas sugestões de mints disponíveis.", + "noRatingsYet": "Ainda sem avaliações", + "cashuMintRating": "★ {rating} · {count} avaliações", + "enterMintUrlManually": "Introduzir URL do mint manualmente", + "bip353WalletTypeTitle": "BIP353", + "lnurlProtocol": "LNURL", + "confirm": "Confirmar", + "reviewWallet": "Rever carteira", + "confirmWalletTitle": "Confirmar carteira", + "confirmWalletDescription": "Reveja estes dados antes de adicionar a carteira.", + "walletDetailType": "Tipo de carteira", + "walletDetailAddress": "Endereço", + "walletDetailDomain": "Domínio", + "walletDetailUrl": "URL", + "walletDetailPublicKey": "Chave pública", + "walletDetailRelay": "Relay", + "walletDetailRelays": "Relays", + "walletDetailSecret": "Segredo da ligação", + "walletSecretHidden": "Presente e oculto por segurança", + "walletDetailDescription": "Descrição", + "walletDetailDetails": "Detalhes", + "walletDetailIssuer": "Emissor", + "walletDetailAmount": "Montante", + "walletDetailCurrency": "Moeda", + "walletDetailExpiry": "Expira", + "walletDetailNodeId": "ID do nó", + "walletDetailOffer": "Oferta BOLT12", + "walletDetailVersion": "Versão", + "walletDetailUnits": "Unidades suportadas", + "walletDetailContact": "Contacto", + "walletDetailTerms": "Termos de serviço", + "walletDetailMessage": "Mensagem", + "walletDetailCommunityRating": "Avaliação da comunidade", + "walletDetailCommunityReviews": "Avaliações recentes da comunidade", "@@locale": "pt", "createAccount": "Criar a sua conta", "newHere": "É novo aqui?", diff --git a/packages/ndk_flutter/lib/l10n/app_pt_BR.arb b/packages/ndk_flutter/lib/l10n/app_pt_BR.arb index 8efc22f8c..2ecad81d9 100644 --- a/packages/ndk_flutter/lib/l10n/app_pt_BR.arb +++ b/packages/ndk_flutter/lib/l10n/app_pt_BR.arb @@ -1,4 +1,79 @@ { + "saveBackupToFile": "Salvar backup em arquivo", + "backupSavedToFile": "Backup salvo em arquivo", + "restoreFromFile": "Restaurar de arquivo", + "backupFileReadFailed": "Não foi possível ler o arquivo de backup selecionado.", + "addBolt12WalletTitle": "Adicionar carteira BOLT12", + "bolt12Input": "Destino de pagamento BOLT12", + "bolt12InputHint": "lno1…, bitcoin:?lno=… ou usuario@dominio.com", + "bolt12Wallet": "Carteira BOLT12", + "bolt12WalletAdded": "Carteira BOLT12 adicionada!", + "bolt12WalletTypeTitle": "Oferta BOLT12", + "enterBolt12Input": "Insira ou escaneie uma oferta lno, um URI bitcoin:?lno=… ou um endereço BIP353.", + "pleaseEnterBolt12Input": "Insira uma oferta BOLT12 ou um endereço BIP353.", + "scanBolt12QrCodeTitle": "Escanear código QR BOLT12", + "walletNameOptional": "Nome da carteira (opcional)", + "fetchingWalletConnectionInfo": "Obtendo informações de conexão da carteira…", + "addWalletDescription": "Escaneie o código QR de uma carteira compatível, cole os dados ou conecte por um aplicativo de carteira.", + "scanWalletQrCode": "Escanear QR da carteira", + "connectWithWallet": "Conectar com uma carteira", + "chooseWalletApp": "Escolher aplicativo de carteira", + "oneClickConnect": "Conexão com 1 clique", + "chooseWallet": "Escolher carteira", + "albyWalletOption": "Alby", + "albyCloudOption": "Alby Cloud", + "coinosWalletOption": "Coinos", + "manualNwcConnection": "Conexão NWC manual", + "walletConnectionFinishIn": "Conclua a conexão em {walletName}", + "walletConnectionConnecting": "Conectando a {walletName}…", + "walletConnectionConnected": "{walletName} conectada", + "walletConnectionFailed": "Não foi possível conectar a {walletName}", + "retry": "Tentar novamente", + "chooseAnotherWallet": "Escolher outra carteira", + "chooseWalletAppDescription": "Aprove uma conexão NWC em uma carteira instalada", + "walletInput": "Endereço ou conexão da carteira", + "walletInputHint": "NWC, endereço Lightning/BIP353, oferta BOLT12/BIP321 ou URL HTTPS de um mint Cashu", + "unsupportedWalletInput": "Este endereço ou conexão de carteira não é compatível.", + "detected": "Detectado", + "lightningAddressInputType": "Endereço Lightning ou BIP353", + "manualWalletSetup": "Configurar manualmente", + "chooseCashuMint": "Escolher mint Cashu", + "cashuMintRatingsNotice": "As avaliações da comunidade vêm de análises Nostr assinadas. Uma avaliação alta não garante que um mint seja seguro.", + "cashuMintDiscoveryFailed": "Não foi possível carregar sugestões de mints.", + "noCashuMintSuggestions": "Nenhuma sugestão de mint disponível foi encontrada.", + "noRatingsYet": "Ainda sem avaliações", + "cashuMintRating": "★ {rating} · {count} avaliações", + "enterMintUrlManually": "Inserir URL do mint manualmente", + "bip353WalletTypeTitle": "BIP353", + "lnurlProtocol": "LNURL", + "confirm": "Confirmar", + "reviewWallet": "Revisar carteira", + "confirmWalletTitle": "Confirmar carteira", + "confirmWalletDescription": "Revise estes dados antes de adicionar a carteira.", + "walletDetailType": "Tipo de carteira", + "walletDetailAddress": "Endereço", + "walletDetailDomain": "Domínio", + "walletDetailUrl": "URL", + "walletDetailPublicKey": "Chave pública", + "walletDetailRelay": "Relay", + "walletDetailRelays": "Relays", + "walletDetailSecret": "Segredo da conexão", + "walletSecretHidden": "Presente e oculto por segurança", + "walletDetailDescription": "Descrição", + "walletDetailDetails": "Detalhes", + "walletDetailIssuer": "Emissor", + "walletDetailAmount": "Valor", + "walletDetailCurrency": "Moeda", + "walletDetailExpiry": "Expira", + "walletDetailNodeId": "ID do nó", + "walletDetailOffer": "Oferta BOLT12", + "walletDetailVersion": "Versão", + "walletDetailUnits": "Unidades compatíveis", + "walletDetailContact": "Contato", + "walletDetailTerms": "Termos de serviço", + "walletDetailMessage": "Mensagem", + "walletDetailCommunityRating": "Avaliação da comunidade", + "walletDetailCommunityReviews": "Avaliações recentes da comunidade", "@@locale": "pt_BR", "createAccount": "Criar sua conta", "newHere": "Você é novo aqui?", diff --git a/packages/ndk_flutter/lib/l10n/app_ru.arb b/packages/ndk_flutter/lib/l10n/app_ru.arb index 70695143e..e8a416f8d 100644 --- a/packages/ndk_flutter/lib/l10n/app_ru.arb +++ b/packages/ndk_flutter/lib/l10n/app_ru.arb @@ -1,4 +1,79 @@ { + "saveBackupToFile": "Сохранить резервную копию в файл", + "backupSavedToFile": "Резервная копия сохранена в файл", + "restoreFromFile": "Восстановить из файла", + "backupFileReadFailed": "Не удалось прочитать выбранный файл резервной копии.", + "addBolt12WalletTitle": "Добавить кошелёк BOLT12", + "bolt12Input": "Цель платежа BOLT12", + "bolt12InputHint": "lno1…, bitcoin:?lno=… или пользователь@домен.com", + "bolt12Wallet": "Кошелёк BOLT12", + "bolt12WalletAdded": "Кошелёк BOLT12 успешно добавлен!", + "bolt12WalletTypeTitle": "Предложение BOLT12", + "enterBolt12Input": "Введите или отсканируйте предложение lno, URI bitcoin:?lno=… или адрес BIP353.", + "pleaseEnterBolt12Input": "Введите предложение BOLT12 или адрес BIP353.", + "scanBolt12QrCodeTitle": "Сканировать QR-код BOLT12", + "walletNameOptional": "Название кошелька (необязательно)", + "fetchingWalletConnectionInfo": "Получение данных подключения кошелька…", + "addWalletDescription": "Отсканируйте поддерживаемый QR-код кошелька, вставьте данные или подключитесь через приложение кошелька.", + "scanWalletQrCode": "Сканировать QR-код кошелька", + "connectWithWallet": "Подключить кошелёк", + "chooseWalletApp": "Выбрать приложение кошелька", + "oneClickConnect": "Подключить в 1 клик", + "chooseWallet": "Выбрать кошелёк", + "albyWalletOption": "Alby", + "albyCloudOption": "Alby Cloud", + "coinosWalletOption": "Coinos", + "manualNwcConnection": "Ручное подключение NWC", + "walletConnectionFinishIn": "Завершите подключение в {walletName}", + "walletConnectionConnecting": "Подключение к {walletName}…", + "walletConnectionConnected": "{walletName} подключён", + "walletConnectionFailed": "Не удалось подключить {walletName}", + "retry": "Повторить", + "chooseAnotherWallet": "Выбрать другой кошелёк", + "chooseWalletAppDescription": "Подтвердите NWC-подключение в установленном кошельке", + "walletInput": "Адрес или подключение кошелька", + "walletInputHint": "NWC, адрес Lightning/BIP353, предложение BOLT12/BIP321 или HTTPS-адрес минта Cashu", + "unsupportedWalletInput": "Этот адрес или подключение кошелька не поддерживается.", + "detected": "Обнаружено", + "lightningAddressInputType": "Адрес Lightning или BIP353", + "manualWalletSetup": "Настроить вручную", + "chooseCashuMint": "Выбрать минт Cashu", + "cashuMintRatingsNotice": "Оценки сообщества взяты из подписанных отзывов Nostr. Высокая оценка не гарантирует безопасность минта.", + "cashuMintDiscoveryFailed": "Не удалось загрузить предложения минтов.", + "noCashuMintSuggestions": "Доступные предложения минтов не найдены.", + "noRatingsYet": "Оценок пока нет", + "cashuMintRating": "★ {rating} · отзывов: {count}", + "enterMintUrlManually": "Ввести URL минта вручную", + "bip353WalletTypeTitle": "BIP353", + "lnurlProtocol": "LNURL", + "confirm": "Подтвердить", + "reviewWallet": "Проверить кошелёк", + "confirmWalletTitle": "Подтвердить кошелёк", + "confirmWalletDescription": "Проверьте эти данные перед добавлением кошелька.", + "walletDetailType": "Тип кошелька", + "walletDetailAddress": "Адрес", + "walletDetailDomain": "Домен", + "walletDetailUrl": "URL", + "walletDetailPublicKey": "Публичный ключ", + "walletDetailRelay": "Ретранслятор", + "walletDetailRelays": "Ретрансляторы", + "walletDetailSecret": "Секрет подключения", + "walletSecretHidden": "Присутствует и скрыт для безопасности", + "walletDetailDescription": "Описание", + "walletDetailDetails": "Сведения", + "walletDetailIssuer": "Эмитент", + "walletDetailAmount": "Сумма", + "walletDetailCurrency": "Валюта", + "walletDetailExpiry": "Истекает", + "walletDetailNodeId": "ID узла", + "walletDetailOffer": "Предложение BOLT12", + "walletDetailVersion": "Версия", + "walletDetailUnits": "Поддерживаемые единицы", + "walletDetailContact": "Контакт", + "walletDetailTerms": "Условия использования", + "walletDetailMessage": "Сообщение", + "walletDetailCommunityRating": "Оценка сообщества", + "walletDetailCommunityReviews": "Недавние отзывы сообщества", "@@locale": "ru", "createAccount": "Создать аккаунт", "newHere": "Вы здесь новенький?", diff --git a/packages/ndk_flutter/lib/l10n/app_sk.arb b/packages/ndk_flutter/lib/l10n/app_sk.arb index 091528a1b..7f97b7573 100644 --- a/packages/ndk_flutter/lib/l10n/app_sk.arb +++ b/packages/ndk_flutter/lib/l10n/app_sk.arb @@ -1,4 +1,79 @@ { + "saveBackupToFile": "Uložiť zálohu do súboru", + "backupSavedToFile": "Záloha bola uložená do súboru", + "restoreFromFile": "Obnoviť zo súboru", + "backupFileReadFailed": "Vybraný súbor zálohy sa nepodarilo prečítať.", + "addBolt12WalletTitle": "Pridať peňaženku BOLT12", + "bolt12Input": "Platobný cieľ BOLT12", + "bolt12InputHint": "lno1…, bitcoin:?lno=… alebo používateľ@doména.com", + "bolt12Wallet": "Peňaženka BOLT12", + "bolt12WalletAdded": "Peňaženka BOLT12 bola pridaná!", + "bolt12WalletTypeTitle": "Ponuka BOLT12", + "enterBolt12Input": "Zadajte alebo naskenujte ponuku lno, URI bitcoin:?lno=… alebo adresu BIP353.", + "pleaseEnterBolt12Input": "Zadajte ponuku BOLT12 alebo adresu BIP353.", + "scanBolt12QrCodeTitle": "Naskenovať QR kód BOLT12", + "walletNameOptional": "Názov peňaženky (voliteľné)", + "fetchingWalletConnectionInfo": "Načítavajú sa údaje pripojenia peňaženky…", + "addWalletDescription": "Naskenujte podporovaný QR kód peňaženky, vložte údaje alebo sa pripojte cez aplikáciu peňaženky.", + "scanWalletQrCode": "Naskenovať QR kód peňaženky", + "connectWithWallet": "Pripojiť peňaženku", + "chooseWalletApp": "Vybrať aplikáciu peňaženky", + "oneClickConnect": "Pripojiť jedným kliknutím", + "chooseWallet": "Vybrať peňaženku", + "albyWalletOption": "Alby", + "albyCloudOption": "Alby Cloud", + "coinosWalletOption": "Coinos", + "manualNwcConnection": "Ručné pripojenie NWC", + "walletConnectionFinishIn": "Dokončite pripojenie v {walletName}", + "walletConnectionConnecting": "Pripája sa {walletName}…", + "walletConnectionConnected": "{walletName} pripojená", + "walletConnectionFailed": "Nepodarilo sa pripojiť {walletName}", + "retry": "Skúsiť znova", + "chooseAnotherWallet": "Vybrať inú peňaženku", + "chooseWalletAppDescription": "Schváľte pripojenie NWC v nainštalovanej peňaženke", + "walletInput": "Adresa alebo pripojenie peňaženky", + "walletInputHint": "NWC, adresa Lightning/BIP353, ponuka BOLT12/BIP321 alebo HTTPS URL Cashu mintu", + "unsupportedWalletInput": "Táto adresa alebo pripojenie peňaženky nie je podporované.", + "detected": "Rozpoznané", + "lightningAddressInputType": "Adresa Lightning alebo BIP353", + "manualWalletSetup": "Nastaviť ručne", + "chooseCashuMint": "Vybrať Cashu mint", + "cashuMintRatingsNotice": "Hodnotenia komunity pochádzajú z podpísaných recenzií Nostr. Vysoké hodnotenie nezaručuje bezpečnosť mintu.", + "cashuMintDiscoveryFailed": "Návrhy mintov sa nepodarilo načítať.", + "noCashuMintSuggestions": "Nenašli sa žiadne dostupné návrhy mintov.", + "noRatingsYet": "Zatiaľ bez hodnotení", + "cashuMintRating": "★ {rating} · {count} recenzií", + "enterMintUrlManually": "Zadať URL mintu ručne", + "bip353WalletTypeTitle": "BIP353", + "lnurlProtocol": "LNURL", + "confirm": "Potvrdiť", + "reviewWallet": "Skontrolovať peňaženku", + "confirmWalletTitle": "Potvrdiť peňaženku", + "confirmWalletDescription": "Pred pridaním peňaženky skontrolujte tieto údaje.", + "walletDetailType": "Typ peňaženky", + "walletDetailAddress": "Adresa", + "walletDetailDomain": "Doména", + "walletDetailUrl": "URL", + "walletDetailPublicKey": "Verejný kľúč", + "walletDetailRelay": "Relay", + "walletDetailRelays": "Relaye", + "walletDetailSecret": "Tajný údaj pripojenia", + "walletSecretHidden": "Prítomný a z bezpečnostných dôvodov skrytý", + "walletDetailDescription": "Popis", + "walletDetailDetails": "Podrobnosti", + "walletDetailIssuer": "Vydavateľ", + "walletDetailAmount": "Suma", + "walletDetailCurrency": "Mena", + "walletDetailExpiry": "Platnosť vyprší", + "walletDetailNodeId": "ID uzla", + "walletDetailOffer": "Ponuka BOLT12", + "walletDetailVersion": "Verzia", + "walletDetailUnits": "Podporované jednotky", + "walletDetailContact": "Kontakt", + "walletDetailTerms": "Podmienky služby", + "walletDetailMessage": "Správa", + "walletDetailCommunityRating": "Hodnotenie komunity", + "walletDetailCommunityReviews": "Najnovšie recenzie komunity", "@@locale": "sk", "createAccount": "Vytvorte si účet", "newHere": "Ste tu prvýkrát?", diff --git a/packages/ndk_flutter/lib/l10n/app_zh.arb b/packages/ndk_flutter/lib/l10n/app_zh.arb index 5c2d72e2b..616284e52 100644 --- a/packages/ndk_flutter/lib/l10n/app_zh.arb +++ b/packages/ndk_flutter/lib/l10n/app_zh.arb @@ -1,4 +1,79 @@ { + "saveBackupToFile": "将备份保存到文件", + "backupSavedToFile": "备份已保存到文件", + "restoreFromFile": "从文件恢复", + "backupFileReadFailed": "无法读取所选备份文件。", + "addBolt12WalletTitle": "添加 BOLT12 钱包", + "bolt12Input": "BOLT12 支付目标", + "bolt12InputHint": "lno1…、bitcoin:?lno=… 或 user@domain.com", + "bolt12Wallet": "BOLT12 钱包", + "bolt12WalletAdded": "BOLT12 钱包添加成功!", + "bolt12WalletTypeTitle": "BOLT12 报价", + "enterBolt12Input": "输入或扫描 lno 报价、bitcoin:?lno=… URI 或 BIP353 地址。", + "pleaseEnterBolt12Input": "请输入 BOLT12 报价或 BIP353 地址。", + "scanBolt12QrCodeTitle": "扫描 BOLT12 二维码", + "walletNameOptional": "钱包名称(可选)", + "fetchingWalletConnectionInfo": "正在获取钱包连接信息…", + "addWalletDescription": "扫描受支持的钱包二维码、粘贴连接信息,或通过钱包应用连接。", + "scanWalletQrCode": "扫描钱包二维码", + "connectWithWallet": "连接钱包", + "chooseWalletApp": "选择钱包应用", + "oneClickConnect": "一键连接", + "chooseWallet": "选择钱包", + "albyWalletOption": "Alby", + "albyCloudOption": "Alby Cloud", + "coinosWalletOption": "Coinos", + "manualNwcConnection": "手动连接 NWC", + "walletConnectionFinishIn": "请在 {walletName} 中完成连接", + "walletConnectionConnecting": "正在连接 {walletName}…", + "walletConnectionConnected": "已连接 {walletName}", + "walletConnectionFailed": "无法连接 {walletName}", + "retry": "重试", + "chooseAnotherWallet": "选择其他钱包", + "chooseWalletAppDescription": "在已安装的钱包中批准 NWC 连接", + "walletInput": "钱包地址或连接信息", + "walletInputHint": "NWC、Lightning/BIP353 地址、BOLT12/BIP321 报价或 Cashu 铸币厂 HTTPS URL", + "unsupportedWalletInput": "不支持此钱包地址或连接信息。", + "detected": "已检测", + "lightningAddressInputType": "Lightning 或 BIP353 地址", + "manualWalletSetup": "手动设置", + "chooseCashuMint": "选择 Cashu 铸币厂", + "cashuMintRatingsNotice": "社区评分来自已签名的 Nostr 评论。高评分并不能保证铸币厂安全。", + "cashuMintDiscoveryFailed": "无法加载铸币厂建议。", + "noCashuMintSuggestions": "未找到可用的铸币厂建议。", + "noRatingsYet": "暂无评分", + "cashuMintRating": "★ {rating} · {count} 条评论", + "enterMintUrlManually": "手动输入铸币厂 URL", + "bip353WalletTypeTitle": "BIP353", + "lnurlProtocol": "LNURL", + "confirm": "确认", + "reviewWallet": "检查钱包", + "confirmWalletTitle": "确认钱包", + "confirmWalletDescription": "添加钱包前请检查这些信息。", + "walletDetailType": "钱包类型", + "walletDetailAddress": "地址", + "walletDetailDomain": "域名", + "walletDetailUrl": "URL", + "walletDetailPublicKey": "公钥", + "walletDetailRelay": "中继", + "walletDetailRelays": "中继", + "walletDetailSecret": "连接密钥", + "walletSecretHidden": "已提供并因安全原因隐藏", + "walletDetailDescription": "说明", + "walletDetailDetails": "详情", + "walletDetailIssuer": "发行方", + "walletDetailAmount": "金额", + "walletDetailCurrency": "货币", + "walletDetailExpiry": "到期时间", + "walletDetailNodeId": "节点 ID", + "walletDetailOffer": "BOLT12 报价", + "walletDetailVersion": "版本", + "walletDetailUnits": "支持的单位", + "walletDetailContact": "联系方式", + "walletDetailTerms": "服务条款", + "walletDetailMessage": "消息", + "walletDetailCommunityRating": "社区评分", + "walletDetailCommunityReviews": "近期社区评论", "@@locale": "zh", "createAccount": "创建账户", "newHere": "您是新用户吗?", diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_add_wallet_dialogs.dart b/packages/ndk_flutter/lib/widgets/wallets/n_add_wallet_dialogs.dart index 7036cac48..288eb0176 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_add_wallet_dialogs.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_add_wallet_dialogs.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io' show Platform; import 'package:android_intent_plus/android_intent.dart'; @@ -25,8 +26,266 @@ typedef NwcUriScanner = Future Function(BuildContext context); /// Opens a host-provided scanner and returns a BOLT12, BIP321, or BIP353 input. typedef Bolt12InputScanner = Future Function(BuildContext context); +/// Result returned by a host-provided wallet scanner screen. +enum WalletInputOrigin { scanner, walletChooser, cashuMintChooser } + +class WalletInputScanResult { + final String? value; + final bool connectionStarted; + final bool manuallyEntered; + final WalletInputOrigin origin; + final CashuMintSuggestion? cashuMintSuggestion; + + const WalletInputScanResult.value( + this.value, { + this.manuallyEntered = false, + this.origin = WalletInputOrigin.scanner, + this.cashuMintSuggestion, + }) : connectionStarted = false; + + const WalletInputScanResult.connectionStarted() + : value = null, + connectionStarted = true, + manuallyEntered = false, + origin = WalletInputOrigin.walletChooser, + cashuMintSuggestion = null; +} + +/// Wallet connection choice displayed inside a host-provided scanner screen. +enum WalletScannerConnectionKind { installedWallet, albyGo, custom } + +class WalletScannerConnectionOption { + final String? id; + final String label; + final String? subtitle; + final WidgetBuilder? iconBuilder; + final Future Function() connect; + final WalletScannerConnectionKind kind; + + const WalletScannerConnectionOption({ + required this.label, + required this.connect, + required this.kind, + this.id, + this.subtitle, + this.iconBuilder, + }); +} + +/// Content and actions for a host-provided wallet scanner screen. +class WalletInputScannerConfiguration { + final String supportedInputDescription; + final String connectionSectionTitle; + final List connectionOptions; + final ValueListenable connectionState; + final Future Function() retryPendingConnection; + final VoidCallback cancelPendingConnection; + final Future> Function() discoverCashuMints; + final Future Function(CashuMintSuggestion suggestion) + enrichCashuMint; + final bool openWalletChooserInitially; + final bool openCashuMintChooserInitially; + + const WalletInputScannerConfiguration({ + required this.supportedInputDescription, + required this.connectionSectionTitle, + required this.connectionOptions, + required this.connectionState, + required this.retryPendingConnection, + required this.cancelPendingConnection, + required this.discoverCashuMints, + required this.enrichCashuMint, + this.openWalletChooserInitially = false, + this.openCashuMintChooserInitially = false, + }); +} + +/// Community-rated Cashu mint discovered from signed NIP-87 events. +class CashuMintSuggestion { + final String url; + final String name; + final String? iconUrl; + final double? averageRating; + final int reviewsCount; + final List reviews; + + const CashuMintSuggestion({ + required this.url, + required this.name, + this.iconUrl, + required this.averageRating, + required this.reviewsCount, + this.reviews = const [], + }); +} + +class CashuMintReview { + final int? rating; + final String comment; + + const CashuMintReview({required this.rating, required this.comment}); +} + +/// Opens a host-provided scanner accepting every supported wallet input. +/// +/// Scanner should explain [WalletInputScannerConfiguration.supportedInputDescription] +/// and render its wallet connection choices alongside camera and paste controls. +typedef WalletInputScanner = + Future Function( + BuildContext context, + WalletInputScannerConfiguration configuration, + ); + +/// Launches a wallet-assisted NWC connection flow. +typedef NwcConnectionLauncher = + Future Function( + BuildContext context, + NdkFlutter ndkFlutter, + NwcWalletAuthCoordinator coordinator, + ); + +/// Host-provided wallet app or web service that can authorize an NWC connection. +class NwcConnectionOption { + final String? id; + final String label; + final String? subtitle; + final WidgetBuilder? iconBuilder; + final NwcConnectionLauncher connect; + + const NwcConnectionOption({ + required this.label, + required this.connect, + this.id, + this.subtitle, + this.iconBuilder, + }); +} + +/// Wallet input categories recognized by the unified add-wallet flow. +enum WalletInputKind { nwc, bolt12, lightningAddress, cashuMint } + +/// Classifies locally recognizable wallet input without performing network I/O. +WalletInputKind? classifyWalletInput(String input) { + final value = _normalizeWalletInput(input); + if (value.isEmpty) return null; + + final uri = Uri.tryParse(value); + if (uri?.scheme.toLowerCase() == 'nostr+walletconnect') { + try { + NostrWalletConnectUri.parseConnectionUri(value); + return WalletInputKind.nwc; + } catch (_) { + return null; + } + } + + final normalizedLower = value.toLowerCase(); + if (normalizedLower.startsWith('lno1') || + (uri?.scheme.toLowerCase() == 'bitcoin' && + uri!.queryParameters.entries.any( + (entry) => + entry.key.toLowerCase() == 'lno' && entry.value.isNotEmpty, + ))) { + return WalletInputKind.bolt12; + } + + var address = value; + if (address.startsWith('₿')) address = address.substring(1); + final addressParts = address.split('@'); + if (addressParts.length == 2 && + addressParts.every((part) => part.isNotEmpty) && + !address.contains(RegExp(r'\s'))) { + return WalletInputKind.lightningAddress; + } + + if (uri?.scheme.toLowerCase() == 'https' && uri!.host.isNotEmpty) { + return WalletInputKind.cashuMint; + } + return null; +} + +String _normalizeWalletInput(String input) { + final value = input.trim(); + if (value.toLowerCase().startsWith('lightning:')) { + return value.substring('lightning:'.length).trim(); + } + return value; +} + +/// Builds client-key web-wallet authorization URL. +Uri buildNwcWebWalletAuthUri({ + required Uri authorizationEndpoint, + required String appName, + required String pubkey, + Map additionalQueryParameters = const {}, +}) { + return authorizationEndpoint.replace( + queryParameters: { + ...authorizationEndpoint.queryParameters, + ...additionalQueryParameters, + 'name': appName, + 'pubkey': pubkey, + }, + ); +} + +/// Builds standard NWC wallet-auth URI handled by compatible wallet apps. +Uri buildNwcWalletAuthUri({ + required String appPubkey, + required AlbyGoConnectConfig config, +}) { + return Uri( + scheme: 'nostr+walletauth', + host: appPubkey, + queryParameters: { + 'relay': config.discoveryRelay, + 'name': config.appName, + 'request_methods': config.requestMethods + .map((method) => method.name) + .join(' '), + 'icon': config.appIconUrl, + 'return_to': config.callback, + }, + ); +} + enum AlbyGoConnectMethod { walletAuth, nostrNwcCallback } +enum WalletConnectionPhase { + idle, + awaitingReturn, + connecting, + connected, + failed, +} + +@immutable +class WalletConnectionState { + final WalletConnectionPhase phase; + final String? walletName; + final String? error; + + const WalletConnectionState._(this.phase, {this.walletName, this.error}); + + const WalletConnectionState.idle() : this._(WalletConnectionPhase.idle); + + const WalletConnectionState.awaitingReturn(String walletName) + : this._(WalletConnectionPhase.awaitingReturn, walletName: walletName); + + const WalletConnectionState.connecting(String walletName) + : this._(WalletConnectionPhase.connecting, walletName: walletName); + + const WalletConnectionState.connected(String walletName) + : this._(WalletConnectionPhase.connected, walletName: walletName); + + const WalletConnectionState.failed(String walletName, String error) + : this._( + WalletConnectionPhase.failed, + walletName: walletName, + error: error, + ); +} + const List _defaultAlbyGoRequestMethods = [ NwcMethod.GET_INFO, NwcMethod.GET_BALANCE, @@ -79,63 +338,288 @@ class NwcWalletAuthCoordinator { _PendingNwcWalletAuthSession? _pendingSession; _PendingNwcCallbackSession? _pendingCallbackSession; String? _lastConnectedWalletId; + bool _isCompletingPendingSession = false; + Future Function()? _retryLaunch; + final ValueNotifier connectionState = ValueNotifier( + const WalletConnectionState.idle(), + ); bool get hasPendingSession => _pendingSession != null; + void cancelPendingConnection() { + _pendingSession = null; + _pendingCallbackSession = null; + _retryLaunch = null; + connectionState.value = const WalletConnectionState.idle(); + } + + /// Clears stale terminal UI state before starting a new add-wallet flow. + /// Active external-wallet sessions remain untouched. + void resetTerminalConnectionState() { + final phase = connectionState.value.phase; + if (phase == WalletConnectionPhase.connected || + phase == WalletConnectionPhase.failed) { + cancelPendingConnection(); + } + } + + Future retryPendingConnection( + BuildContext context, + NdkFlutter ndkFlutter, + ) async { + if (_pendingSession != null) { + return completePendingWalletAuth(context, ndkFlutter); + } + final retryLaunch = _retryLaunch; + if (retryLaunch == null) return false; + await retryLaunch(); + return true; + } + + /// Finishes whichever external-wallet flow was active when app resumes. + /// + /// Callback-based wallets deliver deep link shortly after lifecycle resume. + /// Give that intent brief grace period before treating plain return as failure. + Future handleAppResume( + BuildContext context, + NdkFlutter ndkFlutter, + ) async { + final pendingCallback = _pendingCallbackSession; + if (pendingCallback == null) { + return completePendingWalletAuth(context, ndkFlutter); + } + + await Future.delayed(const Duration(milliseconds: 750)); + if (!identical(_pendingCallbackSession, pendingCallback)) return true; + + _markFailed( + pendingCallback.walletName, + 'Wallet returned without providing a connection. Try again or choose another wallet.', + ); + return true; + } + String? takeLastConnectedWalletId() { final walletId = _lastConnectedWalletId; _lastConnectedWalletId = null; return walletId; } - Future connectAlbyGo( - BuildContext context, - NdkFlutter ndkFlutter, { - AlbyGoConnectConfig config = kDefaultAlbyGoConnectConfig, + void _markAwaiting(String walletName) { + connectionState.value = WalletConnectionState.awaitingReturn(walletName); + } + + void _markFailed(String walletName, Object error) { + connectionState.value = WalletConnectionState.failed( + walletName, + error.toString(), + ); + } + + /// Launches an installed-wallet or web authorization URI and waits for its + /// callback to be passed to [processProtocolUrl]. + Future connectWithUri( + BuildContext context, { + required Uri launchUri, + required String callback, + required String walletName, + }) async { + _retryLaunch = () => connectWithUri( + context, + launchUri: launchUri, + callback: callback, + walletName: walletName, + ); + _pendingSession = null; + _pendingCallbackSession = _PendingNwcCallbackSession( + returnTo: callback, + walletName: walletName, + ); + _markAwaiting(walletName); + + try { + if (!kIsWeb && Platform.isAndroid) { + await AndroidIntent( + action: 'action_view', + data: launchUri.toString(), + ).launch(); + } else { + final launched = await launchUrl( + launchUri, + mode: LaunchMode.externalApplication, + ); + if (!launched) throw StateError('Could not launch wallet app'); + } + } catch (error) { + _pendingCallbackSession = null; + _markFailed(walletName, error); + if (!context.mounted) return; + final l10n = AppLocalizations.of(context)!; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.error(error.toString())), + backgroundColor: Colors.red, + ), + ); + } + } + + /// Opens standard wallet-auth URI in any compatible installed wallet. + Future connectInstalledWallet( + BuildContext context, { + required AlbyGoConnectConfig config, + }) { + return connectWalletAuth(context, config: config, walletName: 'NWC'); + } + + /// Opens standard `nostr+walletauth://` URI in a compatible wallet. + Future connectWalletAuth( + BuildContext context, { + required AlbyGoConnectConfig config, + required String walletName, }) async { if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) return; final appKey = Bip340.generatePrivateKey(); - Uri launchUri; - if (config.connectMethod == AlbyGoConnectMethod.walletAuth) { - launchUri = Uri( - scheme: 'nostr+walletauth', - host: appKey.publicKey, - queryParameters: { - 'relay': config.discoveryRelay, - 'name': config.appName, - 'request_methods': config.requestMethods - .map((method) => method.name) - .join(' '), - 'icon': config.appIconUrl, - 'return_to': config.callback, - }, - ); + _retryLaunch = () => + connectWalletAuth(context, config: config, walletName: walletName); + final launchUri = buildNwcWalletAuthUri( + appPubkey: appKey.publicKey, + config: config, + ); - _pendingSession = _PendingNwcWalletAuthSession( - appKey: appKey, - discoveryRelay: config.discoveryRelay, - returnTo: config.callback, - walletName: config.walletName, + _pendingSession = _PendingNwcWalletAuthSession( + appKey: appKey, + discoveryRelay: config.discoveryRelay, + returnTo: config.callback, + walletName: walletName, + ); + _pendingCallbackSession = null; + _markAwaiting(walletName); + + try { + if (Platform.isAndroid) { + await AndroidIntent( + action: 'action_view', + data: launchUri.toString(), + ).launch(); + } else { + final launched = await launchUrl( + launchUri, + mode: LaunchMode.externalApplication, + ); + if (!launched) throw StateError('Could not launch wallet app'); + } + } catch (error) { + _pendingSession = null; + _markFailed(walletName, error); + if (!context.mounted) return; + final l10n = AppLocalizations.of(context)!; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.error(error.toString())), + backgroundColor: Colors.red, + ), ); - _pendingCallbackSession = null; - } else { - launchUri = Uri( - scheme: 'nostrnwc', - host: config.nostrNwcHost, - queryParameters: { - 'appname': config.appName, - 'appicon': config.appIconUrl, - 'callback': config.callback, - }, + } + } + + /// Starts client-key NWC authorization through a web wallet. + /// + /// [appName] is sent as `name`; generated public key is sent as `pubkey`. + /// Call [completePendingWalletAuth] when host app resumes to discover wallet + /// info event addressed to generated key on [discoveryRelay]. + Future connectWebWalletAuth( + BuildContext context, { + required Uri authorizationEndpoint, + required String appName, + required String discoveryRelay, + required String callback, + required String walletName, + String? walletServicePubkey, + Map additionalQueryParameters = const {}, + }) async { + final appKey = Bip340.generatePrivateKey(); + _retryLaunch = () => connectWebWalletAuth( + context, + authorizationEndpoint: authorizationEndpoint, + appName: appName, + discoveryRelay: discoveryRelay, + callback: callback, + walletName: walletName, + walletServicePubkey: walletServicePubkey, + additionalQueryParameters: additionalQueryParameters, + ); + final launchUri = buildNwcWebWalletAuthUri( + authorizationEndpoint: authorizationEndpoint, + appName: appName, + pubkey: appKey.publicKey, + additionalQueryParameters: additionalQueryParameters, + ); + + _pendingSession = _PendingNwcWalletAuthSession( + appKey: appKey, + discoveryRelay: discoveryRelay, + returnTo: callback, + walletName: walletName, + walletServicePubkey: walletServicePubkey, + ); + _pendingCallbackSession = null; + _markAwaiting(walletName); + + try { + final launched = await launchUrl( + launchUri, + mode: LaunchMode.externalApplication, ); + if (!launched) throw StateError('Could not launch wallet provider'); + } catch (error) { _pendingSession = null; - _pendingCallbackSession = _PendingNwcCallbackSession( - returnTo: config.callback, + _markFailed(walletName, error); + if (!context.mounted) return; + final l10n = AppLocalizations.of(context)!; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.error(error.toString())), + backgroundColor: Colors.red, + ), + ); + } + } + + Future connectAlbyGo( + BuildContext context, + NdkFlutter ndkFlutter, { + AlbyGoConnectConfig config = kDefaultAlbyGoConnectConfig, + }) async { + if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) return; + + if (config.connectMethod == AlbyGoConnectMethod.walletAuth) { + return connectWalletAuth( + context, + config: config, walletName: config.walletName, ); } + _retryLaunch = () => connectAlbyGo(context, ndkFlutter, config: config); + + final launchUri = Uri( + scheme: 'nostrnwc', + host: config.nostrNwcHost, + queryParameters: { + 'appname': config.appName, + 'appicon': config.appIconUrl, + 'callback': config.callback, + }, + ); + _pendingSession = null; + _pendingCallbackSession = _PendingNwcCallbackSession( + returnTo: config.callback, + walletName: config.walletName, + ); + _markAwaiting(config.walletName); + try { if (Platform.isAndroid) { final intent = AndroidIntent( @@ -153,7 +637,8 @@ class NwcWalletAuthCoordinator { } } } catch (e) { - _pendingSession = null; + _pendingCallbackSession = null; + _markFailed(config.walletName, e); if (!context.mounted) return; final l10n = AppLocalizations.of(context)!; ScaffoldMessenger.of(context).showSnackBar( @@ -175,6 +660,51 @@ class NwcWalletAuthCoordinator { ? ScaffoldMessenger.of(context) : null; + final returnedUri = Uri.tryParse(url); + final pendingWalletAuth = _pendingSession; + final returnedRelay = returnedUri?.queryParameters['relay']; + final returnedWalletPubkey = returnedUri?.queryParameters['pubkey']; + if (pendingWalletAuth != null && + _matchesReturnTo(url, pendingWalletAuth.returnTo) && + returnedRelay != null && + returnedRelay.isNotEmpty && + returnedWalletPubkey != null && + returnedWalletPubkey.isNotEmpty) { + try { + connectionState.value = WalletConnectionState.connecting( + pendingWalletAuth.walletName, + ); + final secret = pendingWalletAuth.appKey.privateKey; + if (secret == null) { + throw StateError('Generated wallet auth key is missing private key'); + } + final nwcUri = + 'nostr+walletconnect://$returnedWalletPubkey?relay=${Uri.encodeComponent(returnedRelay)}&secret=$secret'; + await _addNwcWallet( + ndkFlutter, + nwcUri: nwcUri, + walletName: pendingWalletAuth.walletName, + ); + _pendingSession = null; + connectionState.value = WalletConnectionState.connected( + pendingWalletAuth.walletName, + ); + _retryLaunch = null; + if (context.mounted) { + scaffoldMessenger!.showSnackBar( + SnackBar( + content: Text(l10n!.nwcWalletAdded), + backgroundColor: Colors.green, + ), + ); + } + return true; + } catch (error) { + _markFailed(pendingWalletAuth.walletName, error); + return true; + } + } + final callbackNwcUri = _extractNwcUriFromCallback(url); if (callbackNwcUri != null) { final pendingCallbackSession = _pendingCallbackSession; @@ -185,6 +715,11 @@ class NwcWalletAuthCoordinator { } try { + connectionState.value = WalletConnectionState.connecting( + pendingCallbackSession?.walletName ?? + _pendingSession?.walletName ?? + kDefaultAlbyGoConnectConfig.walletName, + ); await _addNwcWallet( ndkFlutter, nwcUri: callbackNwcUri, @@ -193,6 +728,12 @@ class NwcWalletAuthCoordinator { _pendingSession?.walletName ?? kDefaultAlbyGoConnectConfig.walletName, ); + connectionState.value = WalletConnectionState.connected( + pendingCallbackSession?.walletName ?? + _pendingSession?.walletName ?? + kDefaultAlbyGoConnectConfig.walletName, + ); + _retryLaunch = null; if (context.mounted) { scaffoldMessenger!.showSnackBar( SnackBar( @@ -202,6 +743,12 @@ class NwcWalletAuthCoordinator { ); } } catch (e) { + _markFailed( + pendingCallbackSession?.walletName ?? + _pendingSession?.walletName ?? + kDefaultAlbyGoConnectConfig.walletName, + e, + ); if (context.mounted) { scaffoldMessenger!.showSnackBar( SnackBar( @@ -223,16 +770,34 @@ class NwcWalletAuthCoordinator { return false; } - _pendingSession = null; + return completePendingWalletAuth(context, ndkFlutter); + } + + /// Resolves pending client-key wallet authorization from its NWC info event. + /// + /// Useful for web providers that return control by app lifecycle rather than + /// a callback URL. + Future completePendingWalletAuth( + BuildContext context, + NdkFlutter ndkFlutter, + ) async { + final pendingSession = _pendingSession; + if (pendingSession == null || _isCompletingPendingSession) return false; + + _isCompletingPendingSession = true; + connectionState.value = WalletConnectionState.connecting( + pendingSession.walletName, + ); + final l10n = context.mounted ? AppLocalizations.of(context)! : null; + final scaffoldMessenger = context.mounted + ? ScaffoldMessenger.of(context) + : null; + _pendingCallbackSession = null; if (context.mounted) { scaffoldMessenger!.showSnackBar( - const SnackBar( - content: Text( - 'Wallet callback received. Fetching connection info...', - ), - ), + SnackBar(content: Text(l10n!.fetchingWalletConnectionInfo)), ); } @@ -241,7 +806,12 @@ class NwcWalletAuthCoordinator { .query( filter: Filter( kinds: [NwcKind.INFO.value], - pTags: [pendingSession.appKey.publicKey], + authors: pendingSession.walletServicePubkey == null + ? null + : [pendingSession.walletServicePubkey!], + pTags: pendingSession.walletServicePubkey == null + ? [pendingSession.appKey.publicKey] + : null, limit: 1, ), explicitRelays: {pendingSession.discoveryRelay}, @@ -258,8 +828,10 @@ class NwcWalletAuthCoordinator { ); } + final walletServicePubkey = + pendingSession.walletServicePubkey ?? foundWalletAuthEvent.pubKey; final constructedNwcUri = - 'nostr+walletconnect://${foundWalletAuthEvent.pubKey}?relay=${Uri.encodeComponent(pendingSession.discoveryRelay)}&secret=$appPrivateKey'; + 'nostr+walletconnect://$walletServicePubkey?relay=${Uri.encodeComponent(pendingSession.discoveryRelay)}&secret=$appPrivateKey'; await _addNwcWallet( ndkFlutter, @@ -267,6 +839,12 @@ class NwcWalletAuthCoordinator { walletName: pendingSession.walletName, ); + _pendingSession = null; + connectionState.value = WalletConnectionState.connected( + pendingSession.walletName, + ); + _retryLaunch = null; + if (!context.mounted) return true; scaffoldMessenger!.showSnackBar( SnackBar( @@ -276,6 +854,10 @@ class NwcWalletAuthCoordinator { ); return true; } on TimeoutException { + _markFailed( + pendingSession.walletName, + 'Timed out while waiting for wallet connection info from ${pendingSession.discoveryRelay}', + ); if (!context.mounted) return true; scaffoldMessenger!.showSnackBar( SnackBar( @@ -289,6 +871,7 @@ class NwcWalletAuthCoordinator { ); return true; } catch (e) { + _markFailed(pendingSession.walletName, e); if (!context.mounted) return true; scaffoldMessenger!.showSnackBar( SnackBar( @@ -297,6 +880,8 @@ class NwcWalletAuthCoordinator { ), ); return true; + } finally { + _isCompletingPendingSession = false; } } @@ -322,12 +907,14 @@ class _PendingNwcWalletAuthSession { final String discoveryRelay; final String returnTo; final String walletName; + final String? walletServicePubkey; const _PendingNwcWalletAuthSession({ required this.appKey, required this.discoveryRelay, required this.returnTo, required this.walletName, + this.walletServicePubkey, }); } @@ -1183,140 +1770,1302 @@ class _AddBolt12WalletDialogState extends State<_AddBolt12WalletDialog> { } } -/// Shows a dialog to choose wallet type. +/// Shows the unified add-wallet flow. /// -/// Returns true if a wallet type was selected, false if cancelled. -/// Use [albyGoConnectConfig] to override Alby Go app metadata. +/// Scan and paste accept every supported wallet input. Wallet-assisted NWC and +/// type-specific manual setup remain available as alternative paths. Future showAddWalletTypeDialog( BuildContext context, NdkFlutter ndkFlutter, { AlbyGoConnectConfig albyGoConnectConfig = kDefaultAlbyGoConnectConfig, NwcWalletAuthCoordinator? nwcWalletAuthCoordinator, + WalletInputScanner? walletInputScanner, + List nwcConnectionOptions = const [], + bool openScannerOnAdd = true, NwcUriScanner? nwcUriScanner, Bolt12InputScanner? bolt12InputScanner, }) async { - final l10n = AppLocalizations.of(context)!; - + final coordinator = nwcWalletAuthCoordinator ?? NwcWalletAuthCoordinator(); + coordinator.resetTerminalConnectionState(); return await showDialog( context: context, - builder: (dialogContext) => Dialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, + builder: (dialogContext) => _AddWalletDialog( + ndkFlutter: ndkFlutter, + parentContext: context, + albyGoConnectConfig: albyGoConnectConfig, + nwcWalletAuthCoordinator: coordinator, + walletInputScanner: walletInputScanner, + legacyWalletInputScanner: walletInputScanner == null + ? nwcUriScanner ?? bolt12InputScanner + : null, + nwcUriScanner: nwcUriScanner, + bolt12InputScanner: bolt12InputScanner, + nwcConnectionOptions: nwcConnectionOptions, + openScannerOnAdd: openScannerOnAdd && walletInputScanner != null, + ), + ) ?? + false; +} + +class _AddWalletDialog extends StatefulWidget { + final NdkFlutter ndkFlutter; + final BuildContext parentContext; + final AlbyGoConnectConfig albyGoConnectConfig; + final NwcWalletAuthCoordinator nwcWalletAuthCoordinator; + final WalletInputScanner? walletInputScanner; + final Future Function(BuildContext context)? + legacyWalletInputScanner; + final NwcUriScanner? nwcUriScanner; + final Bolt12InputScanner? bolt12InputScanner; + final List nwcConnectionOptions; + final bool openScannerOnAdd; + + const _AddWalletDialog({ + required this.ndkFlutter, + required this.parentContext, + required this.albyGoConnectConfig, + required this.nwcWalletAuthCoordinator, + required this.walletInputScanner, + required this.legacyWalletInputScanner, + required this.nwcUriScanner, + required this.bolt12InputScanner, + required this.nwcConnectionOptions, + required this.openScannerOnAdd, + }); + + @override + State<_AddWalletDialog> createState() => _AddWalletDialogState(); +} + +class _WalletInputPreview { + final String input; + final bool manuallyEntered; + final WalletInputKind detectedKind; + final WalletType walletType; + final String name; + final List<_WalletPreviewDetail> details; + final WalletInputOrigin origin; + final CashuMintSuggestion? cashuMintSuggestion; + final Bolt12ResolvedOffer? resolvedOffer; + final CashuMintInfo? mintInfo; + + const _WalletInputPreview({ + required this.input, + required this.manuallyEntered, + required this.detectedKind, + required this.walletType, + required this.name, + required this.details, + required this.origin, + this.cashuMintSuggestion, + this.resolvedOffer, + this.mintInfo, + }); +} + +class _WalletPreviewDetail { + final String label; + final String value; + + const _WalletPreviewDetail(this.label, this.value); +} + +class _AddWalletDialogState extends State<_AddWalletDialog> { + final _inputController = TextEditingController(); + final _walletNameController = TextEditingController(); + WalletInputKind? _inputKind; + String? _errorMessage; + bool _isAdding = false; + bool _isResolvingDetails = false; + _WalletInputPreview? _preview; + bool _showManualOptions = false; + bool _scannerOpen = false; + + @override + void initState() { + super.initState(); + if (widget.openScannerOnAdd && widget.walletInputScanner != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _scan(); + }); + } + } + + @override + void dispose() { + _inputController.dispose(); + _walletNameController.dispose(); + super.dispose(); + } + + Future _scan({ + WalletInputOrigin initialOrigin = WalletInputOrigin.scanner, + }) async { + final scanner = widget.walletInputScanner; + if (scanner != null) { + if (_scannerOpen) return; + setState(() => _scannerOpen = true); + final result = await scanner( + context, + _scannerConfiguration( + openWalletChooserInitially: + initialOrigin != WalletInputOrigin.scanner, + openCashuMintChooserInitially: + initialOrigin == WalletInputOrigin.cashuMintChooser, + ), + ); + if (!mounted) return; + setState(() => _scannerOpen = false); + if (result == null) { + if (widget.openScannerOnAdd) Navigator.of(context).pop(false); + return; + } + if (result.connectionStarted) { + widget.nwcWalletAuthCoordinator.cancelPendingConnection(); + Navigator.of(context).pop(true); + return; + } + if (result.value != null) { + await _preparePreview( + result.value!, + manuallyEntered: result.manuallyEntered, + origin: result.origin, + cashuMintSuggestion: result.cashuMintSuggestion, + ); + } + return; + } + + final value = await widget.legacyWalletInputScanner?.call(context); + if (!mounted || value == null) return; + await _preparePreview(value); + } + + void _cancelPreview() { + final origin = _preview?.origin ?? WalletInputOrigin.scanner; + setState(() { + _preview = null; + _errorMessage = null; + }); + if (widget.openScannerOnAdd && widget.walletInputScanner != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _scan(initialOrigin: origin); + } + }); + } + } + + WalletInputScannerConfiguration _scannerConfiguration({ + bool openWalletChooserInitially = false, + bool openCashuMintChooserInitially = false, + }) { + final l10n = AppLocalizations.of(context)!; + final options = []; + final showInstalledWallets = + !kIsWeb && (Platform.isAndroid || Platform.isIOS); + + if (showInstalledWallets) { + options.addAll([ + WalletScannerConnectionOption( + id: 'installed-wallet', + label: l10n.chooseWalletApp, + subtitle: l10n.chooseWalletAppDescription, + kind: WalletScannerConnectionKind.installedWallet, + iconBuilder: (_) => const Icon(Icons.account_balance_wallet_outlined), + connect: _launchInstalledWallet, + ), + WalletScannerConnectionOption( + id: 'alby-go', + label: l10n.albyGoOption, + kind: WalletScannerConnectionKind.albyGo, + iconBuilder: (_) => Image.asset( + 'assets/images/albygo.png', + package: 'ndk_flutter', + width: 28, + height: 28, + ), + connect: _launchAlbyGo, + ), + ]); + } + + for (final option in widget.nwcConnectionOptions) { + options.add( + WalletScannerConnectionOption( + id: option.id, + label: option.label, + subtitle: option.subtitle, + kind: WalletScannerConnectionKind.custom, + iconBuilder: option.iconBuilder, + connect: () => _launchConnectionOption(option), + ), + ); + } + + return WalletInputScannerConfiguration( + supportedInputDescription: l10n.walletInputHint, + connectionSectionTitle: l10n.connectWithWallet, + connectionOptions: options, + connectionState: widget.nwcWalletAuthCoordinator.connectionState, + retryPendingConnection: () => widget.nwcWalletAuthCoordinator + .retryPendingConnection(widget.parentContext, widget.ndkFlutter), + cancelPendingConnection: + widget.nwcWalletAuthCoordinator.cancelPendingConnection, + discoverCashuMints: _discoverCashuMints, + enrichCashuMint: _enrichCashuMint, + openWalletChooserInitially: openWalletChooserInitially, + openCashuMintChooserInitially: openCashuMintChooserInitially, + ); + } + + Future> _discoverCashuMints() async { + final recommendations = await widget.ndkFlutter.ndk.cashu + .discoverMintRecommendations(); + final suggestions = recommendations.map((recommendation) { + final info = recommendation.mintInfo; + final fallbackName = + Uri.tryParse(recommendation.url)?.host ?? recommendation.url; + return CashuMintSuggestion( + url: recommendation.url, + name: info?.name?.trim().isNotEmpty == true + ? info!.name!.trim() + : fallbackName, + iconUrl: info?.iconUrl?.trim().isNotEmpty == true + ? info!.iconUrl!.trim() + : null, + averageRating: recommendation.averageRating, + reviewsCount: recommendation.reviewsCount, + reviews: recommendation.reviews + .where((review) => review.comment.isNotEmpty) + .take(5) + .map( + (review) => CashuMintReview( + rating: review.rating, + comment: review.comment, + ), + ) + .toList(), + ); + }).toList(); + final existingMintUrls = (await widget.ndkFlutter.ndk.wallets.getWallets()) + .whereType() + .map((wallet) => wallet.mintUrl.replaceAll(RegExp(r'/+$'), '')) + .toSet(); + return suggestions + .where((suggestion) => !existingMintUrls.contains(suggestion.url)) + .toList(); + } + + Future _enrichCashuMint( + CashuMintSuggestion suggestion, + ) async { + final recommendation = CashuMintRecommendation( + url: suggestion.url, + averageRating: suggestion.averageRating, + reviewsCount: suggestion.reviewsCount, + ); + final enriched = await widget.ndkFlutter.ndk.cashu.enrichMintRecommendation( + recommendation, + ); + final info = enriched.mintInfo; + return CashuMintSuggestion( + url: suggestion.url, + name: info?.name?.trim().isNotEmpty == true + ? info!.name!.trim() + : suggestion.name, + iconUrl: info?.iconUrl?.trim().isNotEmpty == true + ? info!.iconUrl!.trim() + : suggestion.iconUrl, + averageRating: suggestion.averageRating, + reviewsCount: suggestion.reviewsCount, + reviews: suggestion.reviews, + ); + } + + Future _paste() async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + if (!mounted) return; + await _preparePreview(data?.text ?? '', manuallyEntered: true); + } + + void _setInput(String value) { + final normalized = _normalizeWalletInput(value); + final kind = classifyWalletInput(normalized); + setState(() { + _inputController.text = normalized; + _inputController.selection = TextSelection.collapsed( + offset: normalized.length, + ); + _inputKind = kind; + _errorMessage = kind == null + ? AppLocalizations.of(context)!.unsupportedWalletInput + : null; + }); + } + + void _onInputChanged(String value) { + final kind = classifyWalletInput(value); + setState(() { + _inputKind = kind; + _errorMessage = null; + }); + } + + Future _preparePreview( + String rawInput, { + bool manuallyEntered = false, + WalletInputOrigin origin = WalletInputOrigin.scanner, + CashuMintSuggestion? cashuMintSuggestion, + }) async { + final input = _normalizeWalletInput(rawInput); + _setInput(input); + final kind = classifyWalletInput(input); + if (kind == null) return; + + setState(() { + _isResolvingDetails = true; + _errorMessage = null; + }); + + try { + final preview = await _resolvePreview( + input, + kind, + manuallyEntered, + origin, + cashuMintSuggestion, + ); + if (!mounted) return; + setState(() { + _preview = preview; + _walletNameController.text = preview.name; + _walletNameController.selection = TextSelection.collapsed( + offset: preview.name.length, + ); + }); + } catch (error) { + if (!mounted) return; + setState(() => _errorMessage = error.toString()); + } finally { + if (mounted) setState(() => _isResolvingDetails = false); + } + } + + Future<_WalletInputPreview> _resolvePreview( + String input, + WalletInputKind kind, + bool manuallyEntered, + WalletInputOrigin origin, + CashuMintSuggestion? cashuMintSuggestion, + ) async { + final l10n = AppLocalizations.of(context)!; + switch (kind) { + case WalletInputKind.nwc: + final parsed = NostrWalletConnectUri.parseConnectionUri(input); + final relayHost = parsed.relays.isEmpty + ? null + : Uri.tryParse(parsed.relays.first)?.host; + final name = parsed.lud16?.trim().isNotEmpty == true + ? parsed.lud16!.trim() + : relayHost?.isNotEmpty == true + ? 'NWC · $relayHost' + : l10n.nwcWalletTypeTitle; + return _WalletInputPreview( + input: input, + manuallyEntered: manuallyEntered, + origin: origin, + detectedKind: kind, + walletType: WalletType.NWC, + name: name, + details: [ + _WalletPreviewDetail( + l10n.walletDetailType, + l10n.nwcWalletTypeTitle, + ), + if (parsed.lud16?.trim().isNotEmpty == true) + _WalletPreviewDetail( + l10n.walletDetailAddress, + parsed.lud16!.trim(), + ), + _WalletPreviewDetail( + l10n.walletDetailPublicKey, + parsed.walletPubkey, + ), + _WalletPreviewDetail( + parsed.relays.length == 1 + ? l10n.walletDetailRelay + : l10n.walletDetailRelays, + parsed.relays.join('\n'), + ), + _WalletPreviewDetail( + l10n.walletDetailSecret, + l10n.walletSecretHidden, + ), + ], + ); + case WalletInputKind.bolt12: + final resolved = await Bolt12WalletProvider.resolveInput(input); + return _bolt12Preview( + input, + kind, + resolved, + l10n, + manuallyEntered, + origin, + ); + case WalletInputKind.lightningAddress: + try { + final resolved = await Bolt12WalletProvider.resolveInput(input); + return _bolt12Preview( + input, + kind, + resolved, + l10n, + manuallyEntered, + origin, + ); + } catch (_) { + final address = input.replaceFirst('₿', ''); + final parts = address.split('@'); + if (parts.length != 2) { + throw FormatException(l10n.unsupportedWalletInput); + } + final lnurlPayUrl = Uri.https( + parts.last, + '/.well-known/lnurlp/${parts.first}', + ); + final response = await http + .get(lnurlPayUrl) + .timeout(const Duration(seconds: 10)); + final body = response.statusCode >= 200 && response.statusCode < 300 + ? jsonDecode(response.body) + : null; + if (body is! Map || body['tag'] != 'payRequest') { + throw FormatException(l10n.unsupportedWalletInput); + } + return _WalletInputPreview( + input: address, + manuallyEntered: manuallyEntered, + origin: origin, + detectedKind: kind, + walletType: WalletType.LNURL, + name: address, + details: [ + _WalletPreviewDetail(l10n.walletDetailType, l10n.lnurlProtocol), + if (!manuallyEntered) + _WalletPreviewDetail(l10n.walletDetailAddress, address), + ], + ); + } + case WalletInputKind.cashuMint: + final mintInfo = await widget.ndkFlutter.ndk.cashu.getMintInfoNetwork( + mintUrl: input, + ); + final name = mintInfo.name?.trim().isNotEmpty == true + ? mintInfo.name!.trim() + : 'Cashu · ${Uri.parse(input).host}'; + return _WalletInputPreview( + input: input, + manuallyEntered: manuallyEntered, + origin: origin, + cashuMintSuggestion: cashuMintSuggestion, + detectedKind: kind, + walletType: WalletType.CASHU, + name: name, + mintInfo: mintInfo, + details: [ + _WalletPreviewDetail( + l10n.walletDetailType, + l10n.cashuWalletTypeTitle, + ), + _WalletPreviewDetail(l10n.walletDetailUrl, input), + if (cashuMintSuggestion?.averageRating != null) + _WalletPreviewDetail( + l10n.walletDetailCommunityRating, + l10n.cashuMintRating( + cashuMintSuggestion!.averageRating!.toStringAsFixed(1), + cashuMintSuggestion.reviewsCount, + ), + ), + if (cashuMintSuggestion?.reviews.isNotEmpty == true) + _WalletPreviewDetail( + l10n.walletDetailCommunityReviews, + cashuMintSuggestion!.reviews + .map( + (review) => review.rating == null + ? review.comment + : '★ ${review.rating}/5 — ${review.comment}', + ) + .join('\n\n'), + ), + if (mintInfo.description?.trim().isNotEmpty == true) + _WalletPreviewDetail( + l10n.walletDetailDescription, + mintInfo.description!.trim(), + ), + if (mintInfo.descriptionLong?.trim().isNotEmpty == true && + mintInfo.descriptionLong!.trim() != + mintInfo.description?.trim()) + _WalletPreviewDetail( + l10n.walletDetailDetails, + mintInfo.descriptionLong!.trim(), + ), + if (mintInfo.version?.trim().isNotEmpty == true) + _WalletPreviewDetail( + l10n.walletDetailVersion, + mintInfo.version!.trim(), + ), + if (mintInfo.pubkey?.trim().isNotEmpty == true) + _WalletPreviewDetail( + l10n.walletDetailPublicKey, + mintInfo.pubkey!.trim(), + ), + if (mintInfo.supportedUnits.isNotEmpty) + _WalletPreviewDetail( + l10n.walletDetailUnits, + mintInfo.supportedUnits.join(', '), + ), + if (mintInfo.contact.isNotEmpty) + _WalletPreviewDetail( + l10n.walletDetailContact, + mintInfo.contact + .map((contact) => '${contact.method}: ${contact.info}') + .join('\n'), + ), + if (mintInfo.tosUrl?.trim().isNotEmpty == true) + _WalletPreviewDetail( + l10n.walletDetailTerms, + mintInfo.tosUrl!.trim(), + ), + if (mintInfo.motd?.trim().isNotEmpty == true) + _WalletPreviewDetail( + l10n.walletDetailMessage, + mintInfo.motd!.trim(), + ), + ], + ); + } + } + + _WalletInputPreview _bolt12Preview( + String input, + WalletInputKind detectedKind, + Bolt12ResolvedOffer resolved, + AppLocalizations l10n, + bool manuallyEntered, + WalletInputOrigin origin, + ) { + final metadata = resolved.toMetadata(); + final description = metadata['description']?.toString().trim(); + final issuer = metadata['issuer']?.toString().trim(); + final nodeId = metadata['nodeId']?.toString().trim(); + final amount = metadata['amount']?.toString().trim(); + final currency = metadata['currency']?.toString().trim(); + final expiresAt = metadata['expiresAt'] as int?; + final name = + resolved.bip353Address ?? + (issuer?.isNotEmpty == true + ? issuer! + : description?.isNotEmpty == true + ? description! + : l10n.bolt12WalletTypeTitle); + return _WalletInputPreview( + input: input, + manuallyEntered: manuallyEntered, + origin: origin, + detectedKind: detectedKind, + walletType: WalletType.BOLT12, + name: name, + resolvedOffer: resolved, + details: [ + _WalletPreviewDetail( + l10n.walletDetailType, + resolved.bip353Address == null + ? l10n.bolt12WalletTypeTitle + : l10n.bip353WalletTypeTitle, + ), + if (resolved.bip353Address != null && !manuallyEntered) + _WalletPreviewDetail( + l10n.walletDetailAddress, + resolved.bip353Address!, + ), + if (description?.isNotEmpty == true) + _WalletPreviewDetail(l10n.walletDetailDescription, description!), + if (issuer?.isNotEmpty == true) + _WalletPreviewDetail(l10n.walletDetailIssuer, issuer!), + if (amount?.isNotEmpty == true) + _WalletPreviewDetail(l10n.walletDetailAmount, amount!), + if (currency?.isNotEmpty == true) + _WalletPreviewDetail(l10n.walletDetailCurrency, currency!), + if (expiresAt != null) + _WalletPreviewDetail( + l10n.walletDetailExpiry, + DateTime.fromMillisecondsSinceEpoch( + expiresAt * 1000, + isUtc: true, + ).toLocal().toString(), + ), + if (nodeId?.isNotEmpty == true) + _WalletPreviewDetail(l10n.walletDetailNodeId, nodeId!), + _WalletPreviewDetail(l10n.walletDetailOffer, resolved.offer), + ], + ); + } + + Future _confirmInput() async { + var preview = _preview; + if (preview == null) return; + + final input = _normalizeWalletInput(_inputController.text); + final kind = classifyWalletInput(input); + if (kind == null) { + setState(() { + _errorMessage = AppLocalizations.of(context)!.unsupportedWalletInput; + }); + return; + } + + setState(() { + _isAdding = true; + _errorMessage = null; + _inputKind = kind; + }); + + try { + if (input != preview.input || kind != preview.detectedKind) { + preview = await _resolvePreview( + input, + kind, + true, + preview.origin, + preview.cashuMintSuggestion, + ); + if (!mounted) return; + setState(() => _preview = preview); + } + final customName = _walletNameController.text.trim(); + final wallet = await _createWalletFromPreview( + preview, + walletName: customName.isEmpty ? preview.name : customName, + ); + if (!mounted) return; + Navigator.of(context).pop(true); + _showWalletAdded(wallet); + } catch (error) { + if (!mounted) return; + setState(() => _errorMessage = error.toString()); + } finally { + if (mounted) setState(() => _isAdding = false); + } + } + + Future _createWalletFromPreview( + _WalletInputPreview preview, { + required String walletName, + }) async { + switch (preview.walletType) { + case WalletType.NWC: + NostrWalletConnectUri.parseConnectionUri(preview.input); + final wallet = NwcWallet( + id: 'nwc-${DateTime.now().microsecondsSinceEpoch}', + name: walletName, + supportedUnits: const {'sat'}, + nwcUrl: preview.input, + ); + await widget.ndkFlutter.ndk.wallets.addWallet(wallet); + return wallet; + case WalletType.BOLT12: + return _addResolvedBolt12Wallet( + preview.resolvedOffer!, + walletName: walletName, + ); + case WalletType.LNURL: + return _addLnurlWallet(preview.input, walletName: walletName); + case WalletType.CASHU: + return _addCashuWallet( + preview.input, + walletName: walletName, + mintInfo: preview.mintInfo, + ); + } + } + + Future _addResolvedBolt12Wallet( + Bolt12ResolvedOffer resolved, { + required String walletName, + }) async { + final wallet = + widget.ndkFlutter.ndk.wallets.createWallet( + id: 'bolt12-${DateTime.now().microsecondsSinceEpoch}', + name: walletName, + type: WalletType.BOLT12, + supportedUnits: const {'sat'}, + metadata: resolved.toMetadata(), + ) + as Bolt12Wallet; + await widget.ndkFlutter.ndk.wallets.addWallet(wallet); + return wallet; + } + + Future _addLnurlWallet( + String identifier, { + required String walletName, + }) async { + final wallet = widget.ndkFlutter.ndk.wallets.createWallet( + id: 'lnurl-${DateTime.now().microsecondsSinceEpoch}', + name: walletName, + type: WalletType.LNURL, + supportedUnits: const {'sat'}, + metadata: {'identifier': identifier}, + ); + await widget.ndkFlutter.ndk.wallets.addWallet(wallet); + return wallet; + } + + Future _addCashuWallet( + String mintUrl, { + required String walletName, + CashuMintInfo? mintInfo, + }) async { + await widget.ndkFlutter.ndk.cashu.addMintToKnownMints(mintUrl: mintUrl); + final resolvedMintInfo = + mintInfo ?? + await widget.ndkFlutter.ndk.cashu.getMintInfoNetwork(mintUrl: mintUrl); + final wallet = CashuWallet( + id: mintUrl, + name: walletName, + mintUrl: mintUrl, + mintInfo: resolvedMintInfo, + supportedUnits: resolvedMintInfo.supportedUnits, + ); + await widget.ndkFlutter.ndk.wallets.addWallet(wallet); + return wallet; + } + + void _showWalletAdded(Wallet wallet) { + final l10n = AppLocalizations.of(widget.parentContext)!; + final message = switch (wallet.type) { + WalletType.NWC => l10n.nwcWalletAdded, + WalletType.BOLT12 => l10n.bolt12WalletAdded, + WalletType.LNURL => l10n.lnurlWalletAdded, + WalletType.CASHU => l10n.cashuWalletAdded, + }; + ScaffoldMessenger.of(widget.parentContext).showSnackBar( + SnackBar(content: Text(message), backgroundColor: Colors.green), + ); + } + + String _kindLabel(AppLocalizations l10n, WalletInputKind kind) { + return switch (kind) { + WalletInputKind.nwc => l10n.nwcWalletTypeTitle, + WalletInputKind.bolt12 => l10n.bolt12WalletTypeTitle, + WalletInputKind.lightningAddress => l10n.lightningAddressInputType, + WalletInputKind.cashuMint => l10n.cashuWalletTypeTitle, + }; + } + + Future _connectOption(NwcConnectionOption option) async { + Navigator.of(context).pop(true); + await _launchConnectionOption(option); + } + + Future _launchConnectionOption(NwcConnectionOption option) async { + try { + await option.connect( + widget.parentContext, + widget.ndkFlutter, + widget.nwcWalletAuthCoordinator, + ); + } catch (error) { + if (!widget.parentContext.mounted) return; + final l10n = AppLocalizations.of(widget.parentContext)!; + ScaffoldMessenger.of(widget.parentContext).showSnackBar( + SnackBar( + content: Text(l10n.error(error.toString())), + backgroundColor: Colors.red, + ), + ); + } + } + + Future _chooseInstalledWallet() async { + Navigator.of(context).pop(true); + await _launchInstalledWallet(); + } + + Future _launchInstalledWallet() async { + await widget.nwcWalletAuthCoordinator.connectInstalledWallet( + widget.parentContext, + config: widget.albyGoConnectConfig, + ); + } + + Future _connectAlbyGo() async { + Navigator.of(context).pop(true); + await _launchAlbyGo(); + } + + Future _launchAlbyGo() async { + await widget.nwcWalletAuthCoordinator.connectAlbyGo( + widget.parentContext, + widget.ndkFlutter, + config: widget.albyGoConnectConfig, + ); + } + + Future _openManual(WalletType type) async { + Navigator.of(context).pop(true); + switch (type) { + case WalletType.NWC: + await showNwcConnectionOptionsDialog( + widget.parentContext, + widget.ndkFlutter, + albyGoConnectConfig: widget.albyGoConnectConfig, + nwcWalletAuthCoordinator: widget.nwcWalletAuthCoordinator, + nwcUriScanner: widget.nwcUriScanner, + ); + return; + case WalletType.BOLT12: + await showAddBolt12WalletDialog( + widget.parentContext, + widget.ndkFlutter, + bolt12InputScanner: widget.bolt12InputScanner, + ); + return; + case WalletType.LNURL: + await showAddLnurlWalletDialog(widget.parentContext, widget.ndkFlutter); + return; + case WalletType.CASHU: + await showAddCashuWalletDialog(widget.parentContext, widget.ndkFlutter); + return; + } + } + + Widget _buildConfirmationDialog( + BuildContext context, + _WalletInputPreview preview, + ) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + final colors = theme.colorScheme; + final icon = switch (preview.walletType) { + WalletType.NWC => Icons.account_balance_wallet_outlined, + WalletType.BOLT12 => Icons.bolt, + WalletType.LNURL => Icons.alternate_email, + WalletType.CASHU => Icons.toll_outlined, + }; + final mintIconUrl = + preview.cashuMintSuggestion?.iconUrl?.trim().isNotEmpty == true + ? preview.cashuMintSuggestion!.iconUrl!.trim() + : preview.mintInfo?.iconUrl?.trim(); + final fallbackIcon = Icon(icon, size: 32, color: colors.onPrimaryContainer); + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560, maxHeight: 720), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 28, 24, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: colors.primaryContainer, + borderRadius: BorderRadius.circular(18), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(18), + child: + preview.walletType == WalletType.CASHU && + mintIconUrl?.isNotEmpty == true + ? Image.network( + mintIconUrl!, + width: 64, + height: 64, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => fallbackIcon, + ) + : fallbackIcon, + ), + ), + ), + const SizedBox(height: 20), + Text( + l10n.confirmWalletTitle, + style: theme.textTheme.headlineSmall, + ), + const SizedBox(height: 6), + Text( + l10n.confirmWalletDescription, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 22), + if (preview.manuallyEntered) ...[ + TextField( + controller: _inputController, + onChanged: _onInputChanged, + enabled: !_isAdding, + obscureText: + classifyWalletInput(_inputController.text) == + WalletInputKind.nwc, + enableSuggestions: + classifyWalletInput(_inputController.text) != + WalletInputKind.nwc, + autocorrect: false, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.walletInput, + errorText: _inputKind == null ? _errorMessage : null, + ), + ), + const SizedBox(height: 16), + ], + TextField( + controller: _walletNameController, + enabled: !_isAdding, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.walletNameOptional, + hintText: l10n.walletNameHint, + ), + ), + const SizedBox(height: 12), + for ( + var index = 0; + index < preview.details.length; + index++ + ) ...[ + if (index > 0) + Divider(height: 25, color: colors.outlineVariant), + Text( + preview.details[index].label, + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + SelectableText( + preview.details[index].value, + style: theme.textTheme.bodyMedium?.copyWith( + height: 1.4, + ), + ), + ], + if (_errorMessage != null) ...[ + const SizedBox(height: 20), + Text( + _errorMessage!, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.error, + ), + ), + ], + ], + ), + ), + ), + DecoratedBox( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: colors.outlineVariant)), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 20), + child: Row( children: [ - const SizedBox(width: 24), Expanded( - child: Text( - l10n.addWalletTitle, - style: Theme.of(context).textTheme.titleLarge, - textAlign: TextAlign.center, + child: OutlinedButton( + onPressed: _isAdding ? null : _cancelPreview, + child: Text(l10n.cancel), ), ), - GestureDetector( - onTap: () => Navigator.of(dialogContext).pop(false), - child: const Icon(Icons.close, size: 24), + const SizedBox(width: 12), + Expanded( + child: FilledButton( + onPressed: _isAdding ? null : _confirmInput, + child: _isAdding + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Text(l10n.confirm), + ), ), ], ), - const SizedBox(height: 8), - Text( - l10n.chooseWalletType, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Colors.grey), - textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + final kind = _inputKind; + final showInstalledWallets = + !kIsWeb && (Platform.isAndroid || Platform.isIOS); + final preview = _preview; + + if (preview != null) { + return _buildConfirmationDialog(context, preview); + } + + if (widget.openScannerOnAdd && widget.walletInputScanner != null) { + return const SizedBox.shrink(); + } + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560, maxHeight: 720), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 20, 24, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + l10n.addWalletTitle, + style: theme.textTheme.headlineSmall, + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(false), + tooltip: MaterialLocalizations.of( + context, + ).closeButtonTooltip, + icon: const Icon(Icons.close), + ), + ], + ), + Text( + l10n.addWalletDescription, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), + ), + if (widget.walletInputScanner != null || + widget.legacyWalletInputScanner != null) ...[ const SizedBox(height: 24), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - _WalletTypeListOption( - imageAsset: 'assets/images/nwc.png', - title: l10n.nwcWalletTypeTitle, - subtitle: l10n.nwcWalletTypeSubtitle, - infoUrl: 'https://nwc.dev/', - onTap: () async { - Navigator.of(dialogContext).pop(true); - await showNwcConnectionOptionsDialog( - context, - ndkFlutter, - albyGoConnectConfig: albyGoConnectConfig, - nwcWalletAuthCoordinator: nwcWalletAuthCoordinator, - nwcUriScanner: nwcUriScanner, - ); - }, - ), - const SizedBox(height: 12), - _WalletTypeListOption( - icon: Icons.bolt, - title: l10n.lnurlWalletTypeTitle, - subtitle: l10n.lnurlWalletTypeSubtitle, - infoUrl: 'https://lightningaddress.com/', - onTap: () async { - Navigator.of(dialogContext).pop(true); - await showAddLnurlWalletDialog( - context, - ndkFlutter, - returnToWalletType: true, - albyGoConnectConfig: albyGoConnectConfig, - nwcWalletAuthCoordinator: nwcWalletAuthCoordinator, - nwcUriScanner: nwcUriScanner, - ); - }, + FilledButton.icon( + onPressed: _isAdding || _isResolvingDetails ? null : _scan, + icon: const Icon(Icons.qr_code_scanner), + label: Text(l10n.scanWalletQrCode), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(52), + ), + ), + ], + if (showInstalledWallets || + widget.nwcConnectionOptions.isNotEmpty) ...[ + const SizedBox(height: 24), + Text( + l10n.connectWithWallet, + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + if (showInstalledWallets) + _ConnectionOptionTile( + icon: const Icon(Icons.account_balance_wallet_outlined), + title: l10n.chooseWalletApp, + subtitle: l10n.chooseWalletAppDescription, + onTap: _chooseInstalledWallet, + ), + if (showInstalledWallets) ...[ + const SizedBox(height: 8), + _ConnectionOptionTile( + icon: Image.asset( + 'assets/images/albygo.png', + package: 'ndk_flutter', + width: 28, + height: 28, ), - const SizedBox(height: 12), - _WalletTypeListOption( - icon: Icons.electric_bolt, - title: l10n.bolt12WalletTypeTitle, - subtitle: l10n.bolt12WalletTypeSubtitle, - infoUrl: 'https://bolt12.org/', - onTap: () async { - Navigator.of(dialogContext).pop(true); - await showAddBolt12WalletDialog( - context, - ndkFlutter, - returnToWalletType: true, - albyGoConnectConfig: albyGoConnectConfig, - nwcWalletAuthCoordinator: nwcWalletAuthCoordinator, - nwcUriScanner: nwcUriScanner, - bolt12InputScanner: bolt12InputScanner, - ); - }, + title: l10n.albyGoOption, + onTap: _connectAlbyGo, + ), + ], + for (final option in widget.nwcConnectionOptions) ...[ + const SizedBox(height: 8), + _ConnectionOptionTile( + icon: + option.iconBuilder?.call(context) ?? + const Icon(Icons.account_balance_wallet_outlined), + title: option.label, + subtitle: option.subtitle, + onTap: () => _connectOption(option), + ), + ], + ], + const SizedBox(height: 24), + Text(l10n.walletInput, style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + TextField( + controller: _inputController, + onChanged: _onInputChanged, + enabled: !_isAdding && !_isResolvingDetails, + obscureText: kind == WalletInputKind.nwc, + enableSuggestions: kind != WalletInputKind.nwc, + autocorrect: false, + decoration: InputDecoration( + border: const OutlineInputBorder(), + hintText: l10n.walletInputHint, + errorText: _errorMessage, + suffixIcon: IconButton( + onPressed: _isAdding || _isResolvingDetails ? null : _paste, + tooltip: l10n.paste, + icon: const Icon(Icons.content_paste), + ), + ), + ), + if (kind != null) ...[ + const SizedBox(height: 10), + Row( + children: [ + Icon( + Icons.check_circle, + size: 18, + color: theme.colorScheme.primary, ), - const SizedBox(height: 12), - _WalletTypeListOption( - imageAsset: 'assets/images/cashu.png', - title: l10n.cashuWalletTypeTitle, - subtitle: l10n.cashuWalletTypeSubtitle, - infoUrl: 'https://cashu.space/', - onTap: () async { - Navigator.of(dialogContext).pop(true); - await showAddCashuWalletDialog( - context, - ndkFlutter, - returnToWalletType: true, - albyGoConnectConfig: albyGoConnectConfig, - nwcWalletAuthCoordinator: nwcWalletAuthCoordinator, - nwcUriScanner: nwcUriScanner, - ); - }, + const SizedBox(width: 8), + Expanded( + child: Text( + '${l10n.detected}: ${_kindLabel(l10n, kind)}', + style: theme.textTheme.bodyMedium, + ), ), ], ), + const SizedBox(height: 12), + FilledButton( + onPressed: _isAdding || _isResolvingDetails + ? null + : () => _preparePreview( + _inputController.text, + manuallyEntered: true, + ), + child: _isResolvingDetails + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.reviewWallet), + ), ], - ), + const SizedBox(height: 16), + TextButton.icon( + onPressed: _isAdding + ? null + : () => setState( + () => _showManualOptions = !_showManualOptions, + ), + icon: Icon( + _showManualOptions ? Icons.expand_less : Icons.expand_more, + ), + label: Text(l10n.manualWalletSetup), + ), + if (_showManualOptions) ...[ + const SizedBox(height: 8), + _ManualWalletTile( + title: l10n.nwcWalletTypeTitle, + icon: Icons.account_balance_wallet_outlined, + onTap: () => _openManual(WalletType.NWC), + ), + _ManualWalletTile( + title: l10n.lnurlWalletTypeTitle, + icon: Icons.bolt, + onTap: () => _openManual(WalletType.LNURL), + ), + _ManualWalletTile( + title: l10n.bolt12WalletTypeTitle, + icon: Icons.electric_bolt, + onTap: () => _openManual(WalletType.BOLT12), + ), + _ManualWalletTile( + title: l10n.cashuWalletTypeTitle, + icon: Icons.toll, + onTap: () => _openManual(WalletType.CASHU), + ), + ], + ], ), ), - ) ?? - false; + ), + ); + } +} + +class _ConnectionOptionTile extends StatelessWidget { + final Widget icon; + final String title; + final String? subtitle; + final VoidCallback onTap; + + const _ConnectionOptionTile({ + required this.icon, + required this.title, + required this.onTap, + this.subtitle, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + child: ListTile( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + leading: SizedBox.square(dimension: 32, child: Center(child: icon)), + title: Text(title), + subtitle: subtitle == null ? null : Text(subtitle!), + trailing: const Icon(Icons.chevron_right), + onTap: onTap, + ), + ); + } +} + +class _ManualWalletTile extends StatelessWidget { + final String title; + final IconData icon; + final VoidCallback onTap; + + const _ManualWalletTile({ + required this.title, + required this.icon, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Icon(icon), + title: Text(title), + trailing: const Icon(Icons.chevron_right), + onTap: onTap, + ); + } } /// Shows a dialog to choose NWC connection method. @@ -1518,119 +3267,6 @@ class _WalletTypeOptionButton extends StatelessWidget { } } -class _WalletTypeListOption extends StatelessWidget { - final IconData? icon; - final String? imageAsset; - final String title; - final String subtitle; - final String infoUrl; - final VoidCallback onTap; - - const _WalletTypeListOption({ - this.icon, - this.imageAsset, - required this.title, - required this.subtitle, - required this.infoUrl, - required this.onTap, - }) : assert( - icon != null || imageAsset != null, - 'Either icon or imageAsset must be provided', - ); - - @override - Widget build(BuildContext context) { - return Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Theme.of(context).colorScheme.outlineVariant, - ), - ), - child: Row( - children: [ - Container( - width: 64, - height: 64, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(10), - ), - child: imageAsset != null - ? Padding( - padding: const EdgeInsets.all(12), - child: Image.asset( - imageAsset!, - package: 'ndk_flutter', - fit: BoxFit.contain, - ), - ) - : Icon( - icon!, - size: 38, - color: Theme.of(context).colorScheme.primary, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text(title, style: Theme.of(context).textTheme.titleSmall), - const SizedBox(height: 2), - Text( - subtitle, - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 2), - InkWell( - onTap: () => _launchExternalLink(context, infoUrl), - child: Text( - infoUrl, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - ], - ), - ), - Icon( - Icons.chevron_right, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ], - ), - ), - ), - ); - } -} - -Future _launchExternalLink(BuildContext context, String url) async { - final uri = Uri.tryParse(url); - if (uri == null) return; - - final launched = await launchUrl(uri, mode: LaunchMode.externalApplication); - if (launched || !context.mounted) return; - - final l10n = AppLocalizations.of(context)!; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l10n.error('Could not open $url')), - backgroundColor: Colors.red, - ), - ); -} - String? _extractNwcUriFromCallback(String receivedUrl) { const prefix = Nwc.kNWCProtocolPrefix; if (receivedUrl.startsWith(prefix)) { diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_cashu_mint_icon.dart b/packages/ndk_flutter/lib/widgets/wallets/n_cashu_mint_icon.dart new file mode 100644 index 000000000..02c665135 --- /dev/null +++ b/packages/ndk_flutter/lib/widgets/wallets/n_cashu_mint_icon.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:ndk/entities.dart'; + +/// Displays Cashu mint's NUT-06 icon with bundled Cashu asset fallback. +class NCashuMintIcon extends StatelessWidget { + final CashuWallet wallet; + final double size; + final BorderRadius borderRadius; + + const NCashuMintIcon({ + super.key, + required this.wallet, + required this.size, + this.borderRadius = const BorderRadius.all(Radius.circular(8)), + }); + + Widget _fallback() { + return Image.asset( + 'assets/images/cashu.png', + package: 'ndk_flutter', + width: size, + height: size, + fit: BoxFit.contain, + errorBuilder: (_, _, _) => + Icon(Icons.account_balance_wallet, color: Colors.orange, size: size), + ); + } + + @override + Widget build(BuildContext context) { + final iconUrl = wallet.mintInfo.iconUrl?.trim(); + return ClipRRect( + borderRadius: borderRadius, + child: iconUrl?.isNotEmpty == true + ? Image.network( + iconUrl!, + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => _fallback(), + ) + : _fallback(), + ); + } +} 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 9eddb8186..2c9bb9d8e 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_actions.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_actions.dart @@ -3,6 +3,7 @@ import 'package:ndk/entities.dart'; import 'package:ndk_flutter/ndk_flutter.dart'; import '../../l10n/app_localizations.dart'; +import 'n_cashu_mint_icon.dart'; import 'wallet_action_dialogs.dart'; /// Card with Send/Receive actions and dialogs for a selected wallet. @@ -46,9 +47,28 @@ class NWalletActions extends StatefulWidget { class _NWalletActionsState extends State with WalletActionDialogsMixin { + bool _selectionClearScheduled = false; + @override NdkFlutter get ndkFlutter => widget.ndkFlutter; + @override + void didUpdateWidget(covariant NWalletActions oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.selectedWalletId != widget.selectedWalletId) { + _selectionClearScheduled = false; + } + } + + void _clearMissingWalletSelection() { + if (_selectionClearScheduled) return; + _selectionClearScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + widget.onClearSelection?.call(); + }); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -57,16 +77,24 @@ class _NWalletActionsState extends State builder: (context, snapshot) { if (!snapshot.hasData) return const SizedBox.shrink(); - final wallet = snapshot.data!.firstWhere( - (w) => w.id == widget.selectedWalletId, - orElse: () => throw Exception('Wallet not found'), - ); + Wallet? wallet; + for (final candidate in snapshot.data!) { + if (candidate.id == widget.selectedWalletId) { + wallet = candidate; + break; + } + } + if (wallet == null) { + _clearMissingWalletSelection(); + return const SizedBox.shrink(); + } + final selectedWallet = wallet; - final bool isCashu = wallet is CashuWallet; - final bool isNwc = wallet is NwcWallet; - final bool isBolt12 = wallet is Bolt12Wallet; - final bool canSend = wallet.canSend; - final bool canReceive = wallet.canReceive; + final bool isCashu = selectedWallet is CashuWallet; + final bool isNwc = selectedWallet is NwcWallet; + final bool isBolt12 = selectedWallet is Bolt12Wallet; + final bool canSend = selectedWallet.canSend; + final bool canReceive = selectedWallet.canReceive; final bool condensed = widget.condensed; final bool showHeader = widget.showTitle || widget.showCloseButton; final double buttonPadding = condensed ? 8 : 16; @@ -80,18 +108,7 @@ class _NWalletActionsState extends State children: [ if (widget.showTitle) ...[ if (isCashu) - Image.asset( - 'assets/images/cashu.png', - package: 'ndk_flutter', - width: 24, - height: 24, - errorBuilder: (context, error, stackTrace) { - return const Icon( - Icons.account_balance_wallet, - color: Colors.orange, - ); - }, - ) + NCashuMintIcon(wallet: selectedWallet, size: 24) else if (isNwc) Image.asset( 'assets/images/nwc.png', @@ -136,7 +153,8 @@ class _NWalletActionsState extends State if (canSend) Expanded( child: ElevatedButton.icon( - onPressed: () => showSendDialog(context, wallet), + onPressed: () => + showSendDialog(context, selectedWallet), icon: const Icon(Icons.send), label: Text(l10n.send), style: ElevatedButton.styleFrom( @@ -151,7 +169,8 @@ class _NWalletActionsState extends State if (canReceive) Expanded( child: ElevatedButton.icon( - onPressed: () => showReceiveFlow(context, wallet), + onPressed: () => + showReceiveFlow(context, selectedWallet), icon: const Icon(Icons.download), label: Text(l10n.receive), style: ElevatedButton.styleFrom( 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 d3450a350..881daa2f9 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 'n_cashu_mint_icon.dart'; import 'wallet_action_dialogs.dart'; /// Configuration for wallet type icons @@ -357,7 +358,12 @@ class _NWalletCardState extends State // Build main icon widget (full color, not monochromatic) final Widget mainIcon = iconConfig.iconWidget ?? - (defaultAssetName != null + (isCashu + ? NCashuMintIcon( + wallet: widget.wallet as CashuWallet, + size: iconConfig.iconSize, + ) + : defaultAssetName != null ? Image.asset( 'assets/images/$defaultAssetName', package: 'ndk_flutter', diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_wallets.dart b/packages/ndk_flutter/lib/widgets/wallets/n_wallets.dart index 227ea1ab6..d632b8d5e 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_wallets.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_wallets.dart @@ -70,6 +70,15 @@ class NWallets extends StatefulWidget { /// Optional host-provided scanner for BOLT12/BIP321/BIP353 QR codes. final Bolt12InputScanner? bolt12InputScanner; + /// Optional host-provided scanner accepting every supported wallet input. + final WalletInputScanner? walletInputScanner; + + /// Wallet apps or web services offering assisted NWC authorization. + final List nwcConnectionOptions; + + /// Whether to open the host scanner immediately on Android and iOS. + final bool openScannerOnAdd; + /// Custom icon configuration for Cashu wallets final WalletIconConfig? cashuIcon; @@ -105,6 +114,9 @@ class NWallets extends StatefulWidget { this.nwcWalletAuthCoordinator, this.nwcUriScanner, this.bolt12InputScanner, + this.walletInputScanner, + this.nwcConnectionOptions = const [], + this.openScannerOnAdd = true, this.cashuIcon, this.nwcIcon, this.lnurlIcon, @@ -134,7 +146,22 @@ class NWalletsState extends State { ); if (!handled || !mounted) return handled; + _selectConnectedWallet(); + return handled; + } + /// Completes pending external-wallet authorization after host app resumes. + Future resumePendingWalletAuth() async { + final handled = await _nwcWalletAuthCoordinator.handleAppResume( + context, + widget.ndkFlutter, + ); + if (!handled || !mounted) return handled; + _selectConnectedWallet(); + return handled; + } + + void _selectConnectedWallet() { final connectedWalletId = _nwcWalletAuthCoordinator .takeLastConnectedWalletId(); if (connectedWalletId != null) { @@ -143,7 +170,6 @@ class NWalletsState extends State { }); widget.onWalletSelected?.call(connectedWalletId); } - return handled; } @override @@ -278,6 +304,9 @@ class NWalletsState extends State { widget.ndkFlutter, albyGoConnectConfig: widget.albyGoConnectConfig, nwcWalletAuthCoordinator: _nwcWalletAuthCoordinator, + walletInputScanner: widget.walletInputScanner, + nwcConnectionOptions: widget.nwcConnectionOptions, + openScannerOnAdd: widget.openScannerOnAdd, nwcUriScanner: widget.nwcUriScanner, bolt12InputScanner: widget.bolt12InputScanner, ); diff --git a/packages/ndk_flutter/lib/widgets/wallets/wallet_action_dialogs.dart b/packages/ndk_flutter/lib/widgets/wallets/wallet_action_dialogs.dart index 898fb2bdc..81087b30d 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/wallet_action_dialogs.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/wallet_action_dialogs.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; + +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:ndk/entities.dart'; @@ -288,6 +291,20 @@ mixin WalletActionDialogsMixin on State { } } + Future saveToFile() async { + final json = backupJson; + if (json == null) return; + final path = await FilePicker.platform.saveFile( + dialogTitle: l10n.saveBackupToFile, + fileName: + 'cashu-backup-${DateTime.now().toUtc().toIso8601String().replaceAll(':', '-')}.json', + type: FileType.custom, + allowedExtensions: const ['json'], + bytes: Uint8List.fromList(utf8.encode(json)), + ); + if (path != null) displaySuccess(l10n.backupSavedToFile); + } + return AlertDialog( title: Text(l10n.cashuBackupTitle), content: SizedBox( @@ -350,7 +367,7 @@ mixin WalletActionDialogsMixin on State { generating ? l10n.generatingBackup : l10n.backup, ), ) - else + else ...[ TextButton( onPressed: () async { await Clipboard.setData(ClipboardData(text: backupJson!)); @@ -358,6 +375,12 @@ mixin WalletActionDialogsMixin on State { }, child: Text(l10n.copyBackup), ), + TextButton.icon( + onPressed: saveToFile, + icon: const Icon(Icons.save_alt), + label: Text(l10n.saveBackupToFile), + ), + ], ], ); }, @@ -380,6 +403,25 @@ mixin WalletActionDialogsMixin on State { builder: (dialogContext) { return StatefulBuilder( builder: (context, setDialogState) { + Future chooseBackupFile() async { + final picked = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: const ['json'], + withData: true, + ); + if (picked == null) return; + final bytes = picked.files.single.bytes; + if (bytes == null) { + displayError(l10n.backupFileReadFailed); + return; + } + try { + controller.text = utf8.decode(bytes); + } catch (_) { + displayError(l10n.backupFileReadFailed); + } + } + return AlertDialog( title: Text(l10n.cashuRestoreTitle), content: TextField( @@ -392,6 +434,11 @@ mixin WalletActionDialogsMixin on State { ), ), actions: [ + TextButton.icon( + onPressed: restoring ? null : chooseBackupFile, + icon: const Icon(Icons.file_open), + label: Text(l10n.restoreFromFile), + ), TextButton( onPressed: restoring ? null diff --git a/packages/ndk_flutter/pubspec.yaml b/packages/ndk_flutter/pubspec.yaml index bfa6457af..479e31beb 100644 --- a/packages/ndk_flutter/pubspec.yaml +++ b/packages/ndk_flutter/pubspec.yaml @@ -29,6 +29,7 @@ dependencies: flutter_localizations: sdk: flutter flutter_secure_storage: ">=8.0.0 <11.0.0" + file_picker: ^10.3.10 http: ">=1.0.0 <2.0.0" intl: ^0.20.2 ndk: ^0.10.0-dev.2 diff --git a/packages/ndk_flutter/test/wallet_input_classifier_test.dart b/packages/ndk_flutter/test/wallet_input_classifier_test.dart new file mode 100644 index 000000000..00e35847f --- /dev/null +++ b/packages/ndk_flutter/test/wallet_input_classifier_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ndk/domain_layer/usecases/nwc/consts/nwc_method.dart'; +import 'package:ndk_flutter/ndk_flutter.dart'; + +void main() { + test('builds configurable web-wallet authorization URL', () { + final uri = buildNwcWebWalletAuthUri( + authorizationEndpoint: Uri.parse('https://coinos.io/apps/new'), + appName: 'NDK Demo', + pubkey: 'generated-public-key', + ); + + expect(uri.origin, 'https://coinos.io'); + expect(uri.path, '/apps/new'); + expect(uri.queryParameters['name'], 'NDK Demo'); + expect(uri.queryParameters['pubkey'], 'generated-public-key'); + }); + + test('builds generic NWC wallet-auth deep link', () { + const config = AlbyGoConnectConfig( + appName: 'NDK Demo', + appIconUrl: 'https://example.com/icon.png', + callback: 'ndk://nwc', + discoveryRelay: 'wss://relay.example.com', + requestMethods: [NwcMethod.GET_INFO, NwcMethod.PAY_INVOICE], + ); + + final uri = buildNwcWalletAuthUri( + appPubkey: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + config: config, + ); + + expect(uri.scheme, 'nostr+walletauth'); + expect( + uri.host, + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ); + expect(uri.queryParameters['relay'], 'wss://relay.example.com'); + expect(uri.queryParameters['name'], 'NDK Demo'); + expect(uri.queryParameters['request_methods'], 'get_info pay_invoice'); + expect(uri.queryParameters['return_to'], 'ndk://nwc'); + }); + + group('classifyWalletInput', () { + test('recognizes a complete NWC connection URI', () { + const pubkey = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const secret = + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + + expect( + classifyWalletInput( + 'nostr+walletconnect://$pubkey?relay=wss%3A%2F%2Frelay.example&secret=$secret', + ), + WalletInputKind.nwc, + ); + }); + + test('rejects incomplete NWC connection URIs', () { + expect( + classifyWalletInput('nostr+walletconnect://wallet?secret=secret'), + isNull, + ); + }); + + test('recognizes direct and BIP321 BOLT12 inputs', () { + expect(classifyWalletInput('lno1offer'), WalletInputKind.bolt12); + expect( + classifyWalletInput('bitcoin:?lno=lno1offer'), + WalletInputKind.bolt12, + ); + }); + + test('recognizes LNURL and BIP353 address shapes', () { + expect( + classifyWalletInput('alice@example.com'), + WalletInputKind.lightningAddress, + ); + expect( + classifyWalletInput('₿alice@example.com'), + WalletInputKind.lightningAddress, + ); + expect( + classifyWalletInput('lightning:alice@example.com'), + WalletInputKind.lightningAddress, + ); + }); + + test('recognizes secure Cashu mint URLs', () { + expect( + classifyWalletInput('https://mint.example.com'), + WalletInputKind.cashuMint, + ); + expect(classifyWalletInput('http://mint.example.com'), isNull); + }); + + test('rejects payment invoices and arbitrary text', () { + expect(classifyWalletInput('lnbc1invoice'), isNull); + expect(classifyWalletInput('not a wallet'), isNull); + }); + }); +} diff --git a/packages/sample-app/ios/Runner/Info.plist b/packages/sample-app/ios/Runner/Info.plist index 9e6e195d8..79c39634f 100644 --- a/packages/sample-app/ios/Runner/Info.plist +++ b/packages/sample-app/ios/Runner/Info.plist @@ -25,7 +25,7 @@ LSRequiresIPhoneOS NSCameraUsageDescription - Camera access is used to scan NWC wallet connection QR codes. + Camera access is used to scan wallet addresses and connection QR codes. UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/packages/sample-app/lib/login_popup.dart b/packages/sample-app/lib/login_popup.dart index b47d93be9..f3fc37347 100644 --- a/packages/sample-app/lib/login_popup.dart +++ b/packages/sample-app/lib/login_popup.dart @@ -34,7 +34,7 @@ Future showNLoginPopup({ appName: 'NDK sample app', relays: [ "wss://relay.damus.io", - "wss://relay.primal.net", + "wss://nos.lol", "wss://relay.nmail.li", ], ), diff --git a/packages/sample-app/lib/main.dart b/packages/sample-app/lib/main.dart index 799e6ad2c..ffc698029 100644 --- a/packages/sample-app/lib/main.dart +++ b/packages/sample-app/lib/main.dart @@ -21,6 +21,7 @@ bool signerAppAvailable = false; late Ndk ndk; final ndkFlutter = NdkFlutter(ndk: ndk); +Future Function(String url)? activeWalletProtocolHandler; final localeNotifier = ValueNotifier(const Locale('en')); DmLiveState? _dmLiveState; DmLiveState get dmLiveState => _dmLiveState ??= DmLiveState(ndk: ndk)..start(); @@ -101,7 +102,12 @@ class _MyAppState extends State with ProtocolListener { try { final uri = Uri.parse(url); if (uri.scheme == 'ndk' && uri.host == 'nwc') { - appRouter.go('/wallets', extra: url); + final handler = activeWalletProtocolHandler; + if (handler != null) { + handler(url); + } else { + appRouter.go('/wallets', extra: url); + } } } catch (e) { print('MyApp: Error parsing protocol URL: $e'); diff --git a/packages/sample-app/lib/nwc_qr_scanner.dart b/packages/sample-app/lib/nwc_qr_scanner.dart index 57811052a..8fd00b7f1 100644 --- a/packages/sample-app/lib/nwc_qr_scanner.dart +++ b/packages/sample-app/lib/nwc_qr_scanner.dart @@ -1,28 +1,38 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; -import 'package:ndk/ndk.dart'; +import 'package:ndk_flutter/ndk_flutter.dart'; import 'package:ndk_flutter/l10n/app_localizations.dart' as ndk_l10n; -Future scanNwcUri(BuildContext context) { - return showDialog( +Future scanWalletInput( + BuildContext context, + WalletInputScannerConfiguration configuration, +) { + return showDialog( context: context, - builder: (context) => const _NwcQrScannerDialog(), + builder: (context) => _WalletQrScannerDialog( + configuration: configuration, + ), ); } -class _NwcQrScannerDialog extends StatefulWidget { - const _NwcQrScannerDialog(); +class _WalletQrScannerDialog extends StatefulWidget { + final WalletInputScannerConfiguration configuration; + + const _WalletQrScannerDialog({required this.configuration}); @override - State<_NwcQrScannerDialog> createState() => _NwcQrScannerDialogState(); + State<_WalletQrScannerDialog> createState() => _WalletQrScannerDialogState(); } -class _NwcQrScannerDialogState extends State<_NwcQrScannerDialog> { +class _WalletQrScannerDialogState extends State<_WalletQrScannerDialog> { MobileScannerController? _scannerController; bool _hasScanned = false; String? _errorMessage; + bool _closingAfterSuccess = false; bool get _hasCamera => !kIsWeb && @@ -38,48 +48,111 @@ class _NwcQrScannerDialogState extends State<_NwcQrScannerDialog> { facing: CameraFacing.back, ); } + widget.configuration.connectionState.addListener(_onConnectionStateChanged); + if (widget.configuration.openWalletChooserInitially || + widget.configuration.openCashuMintChooserInitially) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _chooseWallet(); + }); + } } @override void dispose() { + widget.configuration.connectionState.removeListener( + _onConnectionStateChanged, + ); _scannerController?.dispose(); super.dispose(); } + void _onConnectionStateChanged() { + if (!mounted) return; + final state = widget.configuration.connectionState.value; + setState(() {}); + if (state.phase == WalletConnectionPhase.connected && + !_closingAfterSuccess) { + _closingAfterSuccess = true; + Future.delayed(const Duration(milliseconds: 1100), () { + if (!mounted) return; + Navigator.of(context).pop( + const WalletInputScanResult.connectionStarted(), + ); + }); + } + } + void _onBarcodeDetected(BarcodeCapture capture) { if (_hasScanned) return; - final l10n = ndk_l10n.AppLocalizations.of(context)!; for (final barcode in capture.barcodes) { final rawValue = barcode.rawValue?.trim(); if (rawValue == null || rawValue.isEmpty) continue; - if (rawValue.startsWith(Nwc.kNWCProtocolPrefix)) { - setState(() => _hasScanned = true); - Navigator.of(context).pop(rawValue); - return; - } - - setState(() { - _errorMessage = l10n.invalidNwcQrCode; - }); + setState(() => _hasScanned = true); + Navigator.of(context).pop(WalletInputScanResult.value(rawValue)); + return; } } Future _pasteFromClipboard() async { - final l10n = ndk_l10n.AppLocalizations.of(context)!; final clipboardData = await Clipboard.getData(Clipboard.kTextPlain); - final text = clipboardData?.text?.trim(); + if (!mounted) return; + await _showManualInput(initialValue: clipboardData?.text ?? ''); + } + + Future _showManualInput({ + String initialValue = '', + bool nwcOnly = false, + }) async { + final result = await showDialog<_ManualWalletInputResult>( + context: context, + builder: (_) => _ManualWalletInputDialog( + initialValue: initialValue, + supportedInputDescription: + widget.configuration.supportedInputDescription, + nwcOnly: nwcOnly, + ), + ); + + if (!mounted || result == null) return; + if (result.connectionStarted) { + Navigator.of(context).pop( + const WalletInputScanResult.connectionStarted(), + ); + return; + } + final value = result.value?.trim(); + if (value == null || value.isEmpty) return; + Navigator.of( + context, + ).pop(WalletInputScanResult.value(value, manuallyEntered: true)); + } + Future _chooseWallet() async { + await _scannerController?.stop(); if (!mounted) return; - if (text != null && text.startsWith(Nwc.kNWCProtocolPrefix)) { - Navigator.of(context).pop(text); + final result = await showDialog( + context: context, + builder: (_) => _WalletChooserDialog( + configuration: widget.configuration, + ), + ); + if (!mounted) return; + if (result == null) { + await _scannerController?.start(); return; } + if (!result.connectionStarted) Navigator.of(context).pop(result); + } - setState(() { - _errorMessage = l10n.invalidNwcUri; - }); + Future _retryConnection() async { + await widget.configuration.retryPendingConnection(); + } + + Future _chooseOtherWallet() async { + widget.configuration.cancelPendingConnection(); + await _chooseWallet(); } @override @@ -87,111 +160,143 @@ class _NwcQrScannerDialogState extends State<_NwcQrScannerDialog> { final l10n = ndk_l10n.AppLocalizations.of(context)!; final hasCamera = _hasCamera; + final connectionState = widget.configuration.connectionState.value; return Dialog( backgroundColor: Colors.black, - child: SizedBox( - width: 400, - height: hasCamera ? 560 : 220, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - IconButton( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.arrow_back, color: Colors.white), - ), - Expanded( - child: Text( - l10n.scanNwcQrCodeTitle, - style: const TextStyle(color: Colors.white, fontSize: 18), - textAlign: TextAlign.center, - ), - ), - IconButton( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.close, color: Colors.white), - ), - ], - ), - ), - if (hasCamera) - Expanded( - child: Stack( - children: [ - MobileScanner( - controller: _scannerController!, - onDetect: _onBarcodeDetected, - ), - Center( - child: Container( - width: 250, - height: 250, - decoration: BoxDecoration( - border: Border.all(color: Colors.white, width: 2), - borderRadius: BorderRadius.circular(12), - ), - ), - ), - if (_errorMessage != null) _buildErrorMessage(), - if (_hasScanned) - Container( - color: Colors.black.withValues(alpha: 0.7), - child: const Center( - child: CircularProgressIndicator(color: Colors.white), - ), - ), - ], - ), - ) - else - Expanded( - child: Stack( - children: [ - Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), + child: Stack( + children: [ + SizedBox( + width: 400, + height: hasCamera ? 680 : 520, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SizedBox(width: 48), + Expanded( child: Text( - l10n.cameraNotAvailable, - style: const TextStyle(color: Colors.white70), + l10n.scanWalletQrCode, + style: const TextStyle( + color: Colors.white, fontSize: 18), textAlign: TextAlign.center, ), ), - ), - if (_errorMessage != null) _buildErrorMessage(), - ], + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, color: Colors.white), + ), + ], + ), ), - ), - Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (hasCamera) ...[ - Text( - l10n.scanNwcInstructions, - style: const TextStyle(color: Colors.white70), - textAlign: TextAlign.center, + if (hasCamera) + Expanded( + child: Stack( + children: [ + MobileScanner( + controller: _scannerController!, + onDetect: _onBarcodeDetected, + ), + Center( + child: Container( + width: 250, + height: 250, + decoration: BoxDecoration( + border: Border.all(color: Colors.white, width: 2), + borderRadius: BorderRadius.circular(12), + ), + ), + ), + if (_errorMessage != null) _buildErrorMessage(), + if (_hasScanned) + Container( + color: Colors.black.withValues(alpha: 0.7), + child: const Center( + child: CircularProgressIndicator( + color: Colors.white), + ), + ), + ], ), - const SizedBox(height: 12), - ], - ElevatedButton.icon( - onPressed: _pasteFromClipboard, - icon: const Icon(Icons.paste), - label: Text(l10n.paste), - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, - foregroundColor: Colors.white, - minimumSize: const Size(double.infinity, 48), + ) + else + Expanded( + child: Stack( + children: [ + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + l10n.cameraNotAvailable, + style: const TextStyle(color: Colors.white70), + textAlign: TextAlign.center, + ), + ), + ), + if (_errorMessage != null) _buildErrorMessage(), + ], ), ), - ], + Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.configuration.supportedInputDescription, + style: const TextStyle(color: Colors.white70), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: + _hasScanned ? null : _pasteFromClipboard, + icon: const Icon(Icons.paste), + label: Text(l10n.paste), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of( + context, + ).colorScheme.primary, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(48), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: _hasScanned ? null : _chooseWallet, + icon: const Icon(Icons.account_balance_wallet), + label: Text(l10n.chooseWallet), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white38), + minimumSize: const Size.fromHeight(48), + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + if (connectionState.phase != WalletConnectionPhase.idle) + Positioned.fill( + child: _ConnectionStatusOverlay( + state: connectionState, + onRetry: _retryConnection, + onChooseOtherWallet: _chooseOtherWallet, ), ), - ], - ), + ], ), ); } @@ -216,3 +321,954 @@ class _NwcQrScannerDialogState extends State<_NwcQrScannerDialog> { ); } } + +class _ConnectionStatusOverlay extends StatelessWidget { + final WalletConnectionState state; + final Future Function() onRetry; + final Future Function() onChooseOtherWallet; + + const _ConnectionStatusOverlay({ + required this.state, + required this.onRetry, + required this.onChooseOtherWallet, + }); + + @override + Widget build(BuildContext context) { + final l10n = ndk_l10n.AppLocalizations.of(context)!; + final failed = state.phase == WalletConnectionPhase.failed; + final connected = state.phase == WalletConnectionPhase.connected; + final walletName = state.walletName ?? l10n.unknownWalletType; + + return ColoredBox( + color: Colors.black.withValues(alpha: 0.94), + child: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (connected) + TweenAnimationBuilder( + tween: Tween(begin: 0.4, end: 1), + duration: const Duration(milliseconds: 420), + curve: Curves.easeOutBack, + builder: (context, scale, child) => Transform.scale( + scale: scale, + child: child, + ), + child: const Icon( + Icons.check_circle, + color: Colors.greenAccent, + size: 88, + ), + ) + else if (failed) + const Icon(Icons.error_outline, + color: Colors.redAccent, size: 72) + else + const CircularProgressIndicator(color: Colors.white), + const SizedBox(height: 24), + Text( + connected + ? l10n.walletConnectionConnected(walletName) + : failed + ? l10n.walletConnectionFailed(walletName) + : state.phase == WalletConnectionPhase.awaitingReturn + ? l10n.walletConnectionFinishIn(walletName) + : l10n.walletConnectionConnecting(walletName), + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + if (failed) ...[ + const SizedBox(height: 12), + if (state.error case final error?) + Text( + error, + style: const TextStyle(color: Colors.white70), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: onRetry, + child: Text(l10n.retry), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: onChooseOtherWallet, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white38), + ), + child: Text(l10n.chooseAnotherWallet), + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +class _ManualWalletInputDialog extends StatefulWidget { + final String initialValue; + final String supportedInputDescription; + final bool nwcOnly; + final Future Function()? connectWalletApp; + + const _ManualWalletInputDialog({ + required this.initialValue, + required this.supportedInputDescription, + required this.nwcOnly, + this.connectWalletApp, + }); + + @override + State<_ManualWalletInputDialog> createState() => + _ManualWalletInputDialogState(); +} + +class _ManualWalletInputDialogState extends State<_ManualWalletInputDialog> { + late final TextEditingController _controller; + WalletInputKind? _kind; + bool _isLaunchingWallet = false; + + bool get _isValid => + _kind != null && (!widget.nwcOnly || _kind == WalletInputKind.nwc); + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialValue.trim()); + _kind = classifyWalletInput(_controller.text); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + String _kindLabel( + ndk_l10n.AppLocalizations l10n, + WalletInputKind kind, + ) { + return switch (kind) { + WalletInputKind.nwc => l10n.nwcWalletTypeTitle, + WalletInputKind.bolt12 => l10n.bolt12WalletTypeTitle, + WalletInputKind.lightningAddress => l10n.lightningAddressInputType, + WalletInputKind.cashuMint => l10n.cashuWalletTypeTitle, + }; + } + + Future _connectWalletApp() async { + final connect = widget.connectWalletApp; + if (connect == null || _isLaunchingWallet) return; + setState(() => _isLaunchingWallet = true); + try { + await connect(); + if (!mounted) return; + Navigator.of(context).pop( + const _ManualWalletInputResult.connectionStarted(), + ); + } finally { + if (mounted) setState(() => _isLaunchingWallet = false); + } + } + + @override + Widget build(BuildContext context) { + final l10n = ndk_l10n.AppLocalizations.of(context)!; + final kind = _kind; + final hasInput = _controller.text.trim().isNotEmpty; + + return AlertDialog( + title: Text(widget.nwcOnly ? l10n.manualNwcConnection : l10n.walletInput), + content: SizedBox( + width: 480, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _controller, + autofocus: true, + autocorrect: false, + obscureText: kind == WalletInputKind.nwc, + enableSuggestions: kind != WalletInputKind.nwc, + keyboardType: TextInputType.url, + minLines: 2, + maxLines: 4, + onChanged: (value) { + setState(() => _kind = classifyWalletInput(value)); + }, + decoration: InputDecoration( + border: const OutlineInputBorder(), + hintText: widget.nwcOnly + ? l10n.nwcConnectionUriHint + : widget.supportedInputDescription, + ), + ), + if (widget.nwcOnly && widget.connectWalletApp != null) ...[ + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _isLaunchingWallet ? null : _connectWalletApp, + icon: _isLaunchingWallet + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.account_balance_wallet_outlined), + label: Text(l10n.oneClickConnect), + ), + ), + ], + if (hasInput) ...[ + const SizedBox(height: 12), + Row( + children: [ + Icon( + _isValid ? Icons.check_circle : Icons.error_outline, + size: 18, + color: _isValid + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.error, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _isValid + ? '${l10n.detected}: ${_kindLabel(l10n, kind!)}' + : l10n.unsupportedWalletInput, + ), + ), + ], + ), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: _isValid + ? () => Navigator.of(context).pop( + _ManualWalletInputResult.value(_controller.text.trim()), + ) + : null, + child: Text(l10n.reviewWallet), + ), + ], + ); + } +} + +class _ManualWalletInputResult { + final String? value; + final bool connectionStarted; + + const _ManualWalletInputResult.value(this.value) : connectionStarted = false; + + const _ManualWalletInputResult.connectionStarted() + : value = null, + connectionStarted = true; +} + +class _WalletChooserDialog extends StatefulWidget { + final WalletInputScannerConfiguration configuration; + + const _WalletChooserDialog({required this.configuration}); + + @override + State<_WalletChooserDialog> createState() => _WalletChooserDialogState(); +} + +class _WalletChooserDialogState extends State<_WalletChooserDialog> { + WalletInputScannerConfiguration get configuration => widget.configuration; + + @override + void initState() { + super.initState(); + if (configuration.openCashuMintChooserInitially) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _openCashu(context); + }); + } + } + + WalletScannerConnectionOption? get _coinosOption { + for (final option in configuration.connectionOptions) { + if (option.id == 'coinos') return option; + } + return null; + } + + WalletScannerConnectionOption? get _installedWalletOption { + for (final option in configuration.connectionOptions) { + if (option.kind == WalletScannerConnectionKind.installedWallet) { + return option; + } + } + return null; + } + + Future _manualNwc(BuildContext context) async { + final result = await showDialog<_ManualWalletInputResult>( + context: context, + builder: (_) => _ManualWalletInputDialog( + initialValue: '', + supportedInputDescription: configuration.supportedInputDescription, + nwcOnly: true, + connectWalletApp: _installedWalletOption?.connect, + ), + ); + if (result != null && context.mounted) { + Navigator.of(context).pop( + result.connectionStarted + ? const WalletInputScanResult.connectionStarted() + : WalletInputScanResult.value( + result.value, + manuallyEntered: true, + origin: WalletInputOrigin.walletChooser, + ), + ); + } + } + + Future _connectCoinos(BuildContext context) async { + final option = _coinosOption; + if (option == null) return; + await option.connect(); + if (context.mounted) { + Navigator.of(context).pop( + const WalletInputScanResult.connectionStarted(), + ); + } + } + + Future _openAlby(BuildContext context) async { + final result = await showDialog( + context: context, + builder: (_) => _AlbyChooserDialog(configuration: configuration), + ); + if (result != null && context.mounted) Navigator.of(context).pop(result); + } + + Future _openCashu(BuildContext context) async { + final result = await showDialog( + context: context, + builder: (_) => _CashuMintChooserDialog(configuration: configuration), + ); + if (result != null && context.mounted) Navigator.of(context).pop(result); + } + + void _returnToScanner(BuildContext context) { + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final l10n = ndk_l10n.AppLocalizations.of(context)!; + + return AlertDialog( + title: Row( + children: [ + Expanded(child: Text(l10n.chooseWallet)), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + content: SizedBox( + width: 440, + child: GridView.count( + shrinkWrap: true, + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + mainAxisExtent: 148, + children: [ + _WalletGridTile( + label: l10n.scanWalletQrCode, + icon: const _BrandIconFrame( + backgroundColor: Colors.black, + child: Icon( + Icons.qr_code_scanner, + color: Colors.white, + size: 42, + ), + ), + onTap: () => _returnToScanner(context), + ), + _WalletGridTile( + label: l10n.albyWalletOption, + icon: const _AlbyHubIcon(), + onTap: () => _openAlby(context), + ), + _WalletGridTile( + label: l10n.coinosWalletOption, + icon: const _CoinosIcon(), + onTap: () => _connectCoinos(context), + ), + _WalletGridTile( + label: l10n.cashuOption, + icon: const _BrandIconFrame( + backgroundColor: Color(0xFFFFF3D7), + child: Image( + image: AssetImage( + 'assets/images/cashu.png', + package: 'ndk_flutter', + ), + width: 44, + height: 44, + ), + ), + onTap: () => _openCashu(context), + ), + _WalletGridTile( + label: 'NWC', + icon: const _NwcIcon(), + onTap: () => _manualNwc(context), + ), + ], + ), + ), + ); + } +} + +class _CashuMintChooserDialog extends StatefulWidget { + final WalletInputScannerConfiguration configuration; + + const _CashuMintChooserDialog({required this.configuration}); + + @override + State<_CashuMintChooserDialog> createState() => + _CashuMintChooserDialogState(); +} + +class _CashuMintChooserDialogState extends State<_CashuMintChooserDialog> { + late Future> _suggestions; + + @override + void initState() { + super.initState(); + _suggestions = widget.configuration.discoverCashuMints(); + } + + void _retry() { + setState(() { + _suggestions = widget.configuration.discoverCashuMints(); + }); + } + + Future _enterMintUrl() async { + final l10n = ndk_l10n.AppLocalizations.of(context)!; + final result = await showDialog<_ManualWalletInputResult>( + context: context, + builder: (_) => _ManualWalletInputDialog( + initialValue: 'https://', + supportedInputDescription: l10n.enterMintUrl, + nwcOnly: false, + ), + ); + final value = result?.value?.trim(); + if (!mounted || value == null) return; + if (classifyWalletInput(value) != WalletInputKind.cashuMint) return; + Navigator.of(context).pop( + WalletInputScanResult.value( + value, + manuallyEntered: true, + origin: WalletInputOrigin.cashuMintChooser, + ), + ); + } + + void _selectMint(CashuMintSuggestion mint) { + Navigator.of(context).pop( + WalletInputScanResult.value( + mint.url, + origin: WalletInputOrigin.cashuMintChooser, + cashuMintSuggestion: mint, + ), + ); + } + + @override + Widget build(BuildContext context) { + final l10n = ndk_l10n.AppLocalizations.of(context)!; + return AlertDialog( + title: Row( + children: [ + Expanded(child: Text(l10n.chooseCashuMint)), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + content: SizedBox( + width: 460, + height: 480, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.cashuMintRatingsNotice, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + Expanded( + child: FutureBuilder>( + future: _suggestions, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.cashuMintDiscoveryFailed, + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _retry, + icon: const Icon(Icons.refresh), + label: Text(l10n.retry), + ), + ], + ), + ); + } + final suggestions = snapshot.data ?? const []; + if (suggestions.isEmpty) { + return Center(child: Text(l10n.noCashuMintSuggestions)); + } + return ListView.separated( + scrollCacheExtent: const ScrollCacheExtent.pixels(0), + itemCount: suggestions.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final mint = suggestions[index]; + return _CashuMintListTile( + mint: mint, + enrich: widget.configuration.enrichCashuMint, + onSelected: _selectMint, + ); + }, + ); + }, + ), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _enterMintUrl, + icon: const Icon(Icons.edit_outlined), + label: Text(l10n.enterMintUrlManually), + ), + ], + ), + ), + ); + } +} + +class _CashuMintListTile extends StatefulWidget { + final CashuMintSuggestion mint; + final Future Function(CashuMintSuggestion mint) enrich; + final ValueChanged onSelected; + + const _CashuMintListTile({ + required this.mint, + required this.enrich, + required this.onSelected, + }); + + @override + State<_CashuMintListTile> createState() => _CashuMintListTileState(); +} + +class _CashuMintListTileState extends State<_CashuMintListTile> { + late final Future _enriched; + bool _selecting = false; + + @override + void initState() { + super.initState(); + _enriched = widget.enrich(widget.mint); + } + + Future _select() async { + if (_selecting) return; + setState(() => _selecting = true); + CashuMintSuggestion mint; + try { + mint = await _enriched; + } catch (_) { + mint = widget.mint; + } + if (mounted) widget.onSelected(mint); + } + + @override + Widget build(BuildContext context) { + final l10n = ndk_l10n.AppLocalizations.of(context)!; + return FutureBuilder( + future: _enriched, + initialData: widget.mint, + builder: (context, snapshot) { + final mint = snapshot.data ?? widget.mint; + final rating = mint.averageRating; + return ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 4, + ), + leading: _CashuMintIcon(mint: mint), + title: Text( + mint.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + mint.url, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + rating == null + ? l10n.noRatingsYet + : l10n.cashuMintRating( + rating.toStringAsFixed(1), + mint.reviewsCount, + ), + ), + ], + ), + trailing: _selecting + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.chevron_right), + onTap: _selecting ? null : _select, + ); + }, + ); + } +} + +class _AlbyChooserDialog extends StatelessWidget { + final WalletInputScannerConfiguration configuration; + + const _AlbyChooserDialog({required this.configuration}); + + WalletScannerConnectionOption? get _albyGoOption { + for (final option in configuration.connectionOptions) { + if (option.kind == WalletScannerConnectionKind.albyGo) return option; + } + return null; + } + + WalletScannerConnectionOption? get _albyCloudOption { + for (final option in configuration.connectionOptions) { + if (option.id == 'alby-cloud') return option; + } + return null; + } + + Future _openCloud(BuildContext context) async { + final option = _albyCloudOption; + if (option == null) return; + await option.connect(); + if (context.mounted) { + Navigator.of(context).pop( + const WalletInputScanResult.connectionStarted(), + ); + } + } + + Future _connectAlbyGo(BuildContext context) async { + final option = _albyGoOption; + if (option == null) return; + await option.connect(); + if (context.mounted) { + Navigator.of(context).pop( + const WalletInputScanResult.connectionStarted(), + ); + } + } + + Future _manualNwc(BuildContext context) async { + final result = await showDialog<_ManualWalletInputResult>( + context: context, + builder: (_) => _ManualWalletInputDialog( + initialValue: '', + supportedInputDescription: configuration.supportedInputDescription, + nwcOnly: true, + ), + ); + if (result?.value != null && context.mounted) { + Navigator.of(context).pop( + WalletInputScanResult.value( + result!.value, + manuallyEntered: true, + origin: WalletInputOrigin.walletChooser, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final l10n = ndk_l10n.AppLocalizations.of(context)!; + return AlertDialog( + title: Text(l10n.albyWalletOption), + content: SizedBox( + width: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const _AlbyHubIcon(), + title: Text(l10n.albyCloudOption), + subtitle: const Text('my.albyhub.com'), + trailing: const Icon(Icons.open_in_new), + enabled: _albyCloudOption != null, + onTap: + _albyCloudOption == null ? null : () => _openCloud(context), + ), + ListTile( + leading: const _AlbyGoIcon(), + title: Text(l10n.albyGoOption), + trailing: const Icon(Icons.chevron_right), + enabled: _albyGoOption != null, + onTap: + _albyGoOption == null ? null : () => _connectAlbyGo(context), + ), + ListTile( + leading: const Icon(Icons.key), + title: Text(l10n.manualNwcConnection), + trailing: const Icon(Icons.chevron_right), + onTap: () => _manualNwc(context), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + ], + ); + } +} + +class _AlbyHubIcon extends StatelessWidget { + const _AlbyHubIcon(); + + @override + Widget build(BuildContext context) { + return _BrandIconFrame( + backgroundColor: Colors.black, + child: SvgPicture.asset( + 'assets/images/albyhub.svg', + package: 'ndk_flutter', + width: 40, + height: 40, + ), + ); + } +} + +class _AlbyGoIcon extends StatelessWidget { + const _AlbyGoIcon(); + + @override + Widget build(BuildContext context) { + return _BrandIconFrame( + backgroundColor: Colors.white, + child: Image.asset( + 'assets/images/albygo.png', + package: 'ndk_flutter', + width: 40, + height: 40, + ), + ); + } +} + +class _CashuMintIcon extends StatelessWidget { + final CashuMintSuggestion mint; + + const _CashuMintIcon({required this.mint}); + + @override + Widget build(BuildContext context) { + final fallback = Icon( + Icons.toll_outlined, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ); + final iconUrl = mint.iconUrl; + + return CircleAvatar( + radius: 24, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + child: ClipOval( + child: iconUrl == null + ? fallback + : Image.network( + iconUrl, + width: 48, + height: 48, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => fallback, + ), + ), + ); + } +} + +class _CoinosIcon extends StatelessWidget { + const _CoinosIcon(); + + @override + Widget build(BuildContext context) { + return _BrandIconFrame( + backgroundColor: Colors.white, + child: SvgPicture.asset( + 'assets/images/coinos.svg', + package: 'ndk_flutter', + width: 48, + height: 48, + ), + ); + } +} + +class _NwcIcon extends StatelessWidget { + const _NwcIcon(); + + @override + Widget build(BuildContext context) { + return _BrandIconFrame( + backgroundColor: Colors.white, + child: Image.asset( + 'assets/images/nwc.png', + package: 'ndk_flutter', + width: 48, + height: 48, + fit: BoxFit.contain, + ), + ); + } +} + +class _BrandIconFrame extends StatelessWidget { + final Color backgroundColor; + final Widget child; + + const _BrandIconFrame({required this.backgroundColor, required this.child}); + + @override + Widget build(BuildContext context) { + final dark = Theme.of(context).brightness == Brightness.dark; + + return Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: dark + ? Colors.white.withValues(alpha: 0.15) + : Colors.black.withValues(alpha: 0.25), + offset: const Offset(0, 10), + blurRadius: 15, + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: ColoredBox( + color: backgroundColor, + child: Center(child: child), + ), + ), + ); + } +} + +class _WalletGridTile extends StatelessWidget { + final String label; + final Widget icon; + final VoidCallback onTap; + + const _WalletGridTile({ + required this.label, + required this.icon, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(16), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 12, 8, 8), + child: Column( + children: [ + icon, + const SizedBox(height: 8), + Expanded( + child: Center( + child: Tooltip( + message: label, + child: Text( + label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/sample-app/lib/wallets.dart b/packages/sample-app/lib/wallets.dart index d14057676..18b71fa6b 100644 --- a/packages/sample-app/lib/wallets.dart +++ b/packages/sample-app/lib/wallets.dart @@ -3,9 +3,14 @@ import 'package:ndk_demo/l10n/app_localizations_context.dart'; import 'package:ndk_flutter/ndk_flutter.dart'; import 'main.dart'; -import 'bolt12_qr_scanner.dart'; import 'nwc_qr_scanner.dart'; +const _sampleAppName = 'NDK Demo'; +const _sampleCallback = 'ndk://nwc'; +const _coinosRelay = 'wss://relay.coinos.io'; +const _coinosWalletServicePubkey = + 'ba80990666ef0b6f4ba5059347beb13242921e54669e680064ca755256a1e3a6'; + class WalletsPage extends StatefulWidget { final String? initialUrl; @@ -24,6 +29,7 @@ class WalletsPageState extends State with WidgetsBindingObserver { void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + activeWalletProtocolHandler = onProtocolUrlReceived; _appLifecycleState = WidgetsBinding.instance.lifecycleState; if (widget.initialUrl != null) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -34,6 +40,9 @@ class WalletsPageState extends State with WidgetsBindingObserver { @override void dispose() { + if (activeWalletProtocolHandler == onProtocolUrlReceived) { + activeWalletProtocolHandler = null; + } WidgetsBinding.instance.removeObserver(this); super.dispose(); } @@ -47,13 +56,13 @@ class WalletsPageState extends State with WidgetsBindingObserver { final deferredProtocolUrl = _deferredProtocolUrl; _deferredProtocolUrl = null; - if (deferredProtocolUrl == null) { - return; - } - WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - _walletsKey.currentState?.onProtocolUrlReceived(deferredProtocolUrl); + if (deferredProtocolUrl != null) { + _walletsKey.currentState?.onProtocolUrlReceived(deferredProtocolUrl); + } else { + _walletsKey.currentState?.resumePendingWalletAuth(); + } }); } @@ -73,8 +82,43 @@ class WalletsPageState extends State with WidgetsBindingObserver { body: NWallets( key: _walletsKey, ndkFlutter: ndkFlutter, - nwcUriScanner: scanNwcUri, - bolt12InputScanner: scanBolt12Input, + walletInputScanner: scanWalletInput, + nwcConnectionOptions: [ + NwcConnectionOption( + id: 'alby-cloud', + label: 'Alby Cloud', + connect: (context, ndkFlutter, coordinator) { + return coordinator.connectWebWalletAuth( + context, + authorizationEndpoint: Uri.parse( + 'https://my.albyhub.com/apps/new', + ), + appName: _sampleAppName, + discoveryRelay: kDefaultAlbyGoConnectConfig.discoveryRelay, + callback: _sampleCallback, + walletName: 'Alby Cloud', + additionalQueryParameters: const { + 'return_to': _sampleCallback, + }, + ); + }, + ), + NwcConnectionOption( + id: 'coinos', + label: 'Coinos', + connect: (context, ndkFlutter, coordinator) { + return coordinator.connectWebWalletAuth( + context, + authorizationEndpoint: Uri.parse('https://coinos.io/apps/new'), + appName: _sampleAppName, + discoveryRelay: _coinosRelay, + callback: _sampleCallback, + walletName: 'Coinos', + walletServicePubkey: _coinosWalletServicePubkey, + ); + }, + ), + ], ), ); } diff --git a/packages/sample-app/lib/widgets_demo_page.dart b/packages/sample-app/lib/widgets_demo_page.dart index 7177311c3..6f24a5c72 100644 --- a/packages/sample-app/lib/widgets_demo_page.dart +++ b/packages/sample-app/lib/widgets_demo_page.dart @@ -218,7 +218,7 @@ class _WidgetsDemoPageState extends State { appName: 'NDK sample app', relays: [ "wss://relay.damus.io", - "wss://relay.primal.net", + "wss://nos.lol", "wss://relay.nmail.li", ], ), diff --git a/packages/sample-app/pubspec.lock b/packages/sample-app/pubspec.lock index 93eb9f82c..f0f2c234f 100644 --- a/packages/sample-app/pubspec.lock +++ b/packages/sample-app/pubspec.lock @@ -323,6 +323,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -642,6 +650,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -1103,6 +1119,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.3" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935" + url: "https://pub.dev" + source: hosted + version: "1.2.3" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682" + url: "https://pub.dev" + source: hosted + version: "1.3.0" vector_math: dependency: transitive description: diff --git a/packages/sample-app/pubspec.yaml b/packages/sample-app/pubspec.yaml index ba671b7ae..93ecf291c 100644 --- a/packages/sample-app/pubspec.yaml +++ b/packages/sample-app/pubspec.yaml @@ -53,6 +53,7 @@ dependencies: http: ^1.2.0 qr_flutter: ^4.1.0 mobile_scanner: ^7.2.1 + flutter_svg: ^2.2.1 convert: ^3.1.2 crypto: ^3.0.7 From 4979ac5ae17a4c39860a1507ddc90101ceb5802f Mon Sep 17 00:00:00 2001 From: fmar Date: Sat, 12 Sep 2026 12:27:46 +0200 Subject: [PATCH 17/22] test(cashu): cover recommendation edge cases --- .../cashu_mint_recommendations_test.dart | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/packages/ndk/test/cashu/cashu_mint_recommendations_test.dart b/packages/ndk/test/cashu/cashu_mint_recommendations_test.dart index d69875872..ae4f39d44 100644 --- a/packages/ndk/test/cashu/cashu_mint_recommendations_test.dart +++ b/packages/ndk/test/cashu/cashu_mint_recommendations_test.dart @@ -1,4 +1,5 @@ import 'package:ndk/ndk.dart'; +import 'package:ndk/domain_layer/entities/cashu/cashu_mint_info.dart'; import 'package:test/test.dart'; void main() { @@ -54,6 +55,66 @@ void main() { expect(recommendations.single.reviews.single.comment, 'Useful comment without rating'); }); + + test('ignores unrelated events and ranks equal review counts by rating', + () { + const mintA = 'https://mint-a.example'; + const mintB = 'https://mint-b.example'; + final recommendations = CashuMintRecommendations.fromEvents( + announcements: [ + _event(kind: 1, pubkey: 'ignored', tags: const [ + ['u', 'https://ignored.example'] + ]), + _event(kind: 38172, pubkey: 'a', tags: const [ + ['u'], + ['u', mintA] + ]), + _event(kind: 38172, pubkey: 'b', tags: const [ + ['u', mintB] + ]), + ], + reviews: [ + _event( + kind: 38000, + pubkey: 'ignored', + tags: const [ + ['u', mintA] + ], + ), + _review(mintA, pubkey: 'one', createdAt: 10, content: '[6/5] bad'), + _review(mintB, pubkey: 'two', createdAt: 20, content: '[4/5] good'), + ], + ); + + expect(recommendations.map((item) => item.url), [mintB, mintA]); + expect(recommendations.last.averageRating, isNull); + expect(recommendations.last.reviews.single.comment, 'bad'); + }); + + test('copyWith preserves review data while attaching mint info', () { + const review = CashuMintReview( + reviewerPubkey: 'reviewer', + createdAt: 42, + rating: 5, + comment: 'Reliable', + ); + const recommendation = CashuMintRecommendation( + url: 'https://mint.example', + averageRating: 5, + reviewsCount: 1, + reviews: [review], + ); + final mintInfo = CashuMintInfo(name: 'Example Mint', nuts: const {}); + + final enriched = recommendation.copyWith(mintInfo: mintInfo); + + expect(enriched.url, recommendation.url); + expect(enriched.mintInfo, same(mintInfo)); + expect(enriched.averageRating, recommendation.averageRating); + expect(enriched.reviewsCount, recommendation.reviewsCount); + expect(enriched.reviews, same(recommendation.reviews)); + expect(enriched.copyWith().mintInfo, same(mintInfo)); + }); }); } From d4a3ee825f81dfca569c9cef9aeb852477155ba9 Mon Sep 17 00:00:00 2001 From: fmar Date: Sat, 12 Sep 2026 12:58:10 +0200 Subject: [PATCH 18/22] feat(wallets): add LNbits provider --- .../models/wallet_transaction_model.dart | 1 + .../providers/lnbits/lnbits_wallet.dart | 69 +++ .../lnbits/lnbits_wallet_provider.dart | 464 ++++++++++++++++++ .../entities/wallet/wallet_factory.dart | 11 + .../entities/wallet/wallet_transaction.dart | 1 + .../entities/wallet/wallet_type.dart | 4 +- packages/ndk/lib/presentation_layer/init.dart | 10 +- .../ndk/test/entities/lnbits_wallet_test.dart | 217 ++++++++ packages/ndk_flutter/assets/images/lnbits.svg | 4 + packages/ndk_flutter/lib/l10n/app_de.arb | 7 + packages/ndk_flutter/lib/l10n/app_en.arb | 7 + packages/ndk_flutter/lib/l10n/app_es.arb | 7 + packages/ndk_flutter/lib/l10n/app_fi.arb | 7 + packages/ndk_flutter/lib/l10n/app_fr.arb | 7 + packages/ndk_flutter/lib/l10n/app_it.arb | 7 + packages/ndk_flutter/lib/l10n/app_ja.arb | 7 + .../lib/l10n/app_localizations.dart | 42 ++ .../lib/l10n/app_localizations_de.dart | 23 + .../lib/l10n/app_localizations_en.dart | 23 + .../lib/l10n/app_localizations_es.dart | 23 + .../lib/l10n/app_localizations_fi.dart | 23 + .../lib/l10n/app_localizations_fr.dart | 23 + .../lib/l10n/app_localizations_it.dart | 23 + .../lib/l10n/app_localizations_ja.dart | 22 + .../lib/l10n/app_localizations_pl.dart | 23 + .../lib/l10n/app_localizations_pt.dart | 46 ++ .../lib/l10n/app_localizations_ru.dart | 23 + .../lib/l10n/app_localizations_sk.dart | 22 + .../lib/l10n/app_localizations_zh.dart | 22 + packages/ndk_flutter/lib/l10n/app_pl.arb | 7 + packages/ndk_flutter/lib/l10n/app_pt.arb | 7 + packages/ndk_flutter/lib/l10n/app_pt_BR.arb | 7 + packages/ndk_flutter/lib/l10n/app_ru.arb | 7 + packages/ndk_flutter/lib/l10n/app_sk.arb | 7 + packages/ndk_flutter/lib/l10n/app_zh.arb | 7 + .../widgets/wallets/n_add_wallet_dialogs.dart | 231 +++++++-- .../lib/widgets/wallets/n_lnbits_icon.dart | 40 ++ .../lib/widgets/wallets/n_wallet_actions.dart | 5 + .../lib/widgets/wallets/n_wallet_card.dart | 25 +- packages/ndk_flutter/lib/widgets/widgets.dart | 1 + packages/ndk_flutter/pubspec.yaml | 1 + packages/sample-app/lib/nwc_qr_scanner.dart | 169 +++++++ packages/sample-app/pubspec.lock | 6 +- 43 files changed, 1653 insertions(+), 35 deletions(-) create mode 100644 packages/ndk/lib/domain_layer/entities/wallet/providers/lnbits/lnbits_wallet.dart create mode 100644 packages/ndk/lib/domain_layer/entities/wallet/providers/lnbits/lnbits_wallet_provider.dart create mode 100644 packages/ndk/test/entities/lnbits_wallet_test.dart create mode 100644 packages/ndk_flutter/assets/images/lnbits.svg create mode 100644 packages/ndk_flutter/lib/widgets/wallets/n_lnbits_icon.dart diff --git a/packages/ndk/lib/data_layer/models/wallet_transaction_model.dart b/packages/ndk/lib/data_layer/models/wallet_transaction_model.dart index 51922efbc..410a563b2 100644 --- a/packages/ndk/lib/data_layer/models/wallet_transaction_model.dart +++ b/packages/ndk/lib/data_layer/models/wallet_transaction_model.dart @@ -20,6 +20,7 @@ class WalletTransactionModel { return NwcWalletTransactionModel.fromJson(json); case WalletType.LNURL: case WalletType.BOLT12: + case WalletType.LNBITS: return LnurlWalletTransactionModel.fromJson(json); } } diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/lnbits/lnbits_wallet.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/lnbits/lnbits_wallet.dart new file mode 100644 index 000000000..0e722c424 --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/lnbits/lnbits_wallet.dart @@ -0,0 +1,69 @@ +import '../../wallet.dart'; +import '../../wallet_type.dart'; + +/// Wallet backed by an LNbits instance and wallet Admin Key. +class LnBitsWallet extends Wallet { + static const String urlMetadataKey = 'lnbitsUrl'; + static const String adminKeyMetadataKey = 'adminKey'; + static const String remoteWalletIdMetadataKey = 'remoteWalletId'; + + final String lnbitsUrl; + final String adminKey; + final String? remoteWalletId; + + LnBitsWallet({ + required super.id, + required super.name, + super.type = WalletType.LNBITS, + required super.supportedUnits, + required this.lnbitsUrl, + required this.adminKey, + this.remoteWalletId, + Map? metadata, + }) : super( + metadata: Map.unmodifiable({ + ...(metadata ?? const {}), + urlMetadataKey: lnbitsUrl, + adminKeyMetadataKey: adminKey, + if (remoteWalletId != null) + remoteWalletIdMetadataKey: remoteWalletId, + }), + ); + + @override + bool get canReceive => true; + + @override + bool get canSend => true; + + @override + Map toMetadata() => metadata; + + static LnBitsWallet fromStorage({ + required String id, + required String name, + required Set supportedUnits, + required Map metadata, + }) { + final url = metadata[urlMetadataKey] as String?; + final adminKey = metadata[adminKeyMetadataKey] as String?; + if (url == null || url.isEmpty) { + throw ArgumentError( + 'LNbits storage requires metadata["$urlMetadataKey"]'); + } + if (adminKey == null || adminKey.isEmpty) { + throw ArgumentError( + 'LNbits storage requires metadata["$adminKeyMetadataKey"]', + ); + } + return LnBitsWallet( + id: id, + name: name, + supportedUnits: supportedUnits, + lnbitsUrl: url, + adminKey: adminKey, + remoteWalletId: metadata[remoteWalletIdMetadataKey] as String?, + metadata: metadata, + ); + } +} diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/lnbits/lnbits_wallet_provider.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/lnbits/lnbits_wallet_provider.dart new file mode 100644 index 000000000..25ff2e93a --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/lnbits/lnbits_wallet_provider.dart @@ -0,0 +1,464 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../../../../usecases/nwc/responses/pay_invoice_response.dart'; +import '../../../../usecases/nwc/responses/pay_response.dart'; +import '../../../../usecases/nwc/responses/receive_response.dart'; +import '../../bip321.dart'; +import '../../wallet.dart'; +import '../../wallet_balance.dart'; +import '../../wallet_provider.dart'; +import '../../wallet_transaction.dart'; +import '../../wallet_type.dart'; +import 'lnbits_wallet.dart'; + +class LnBitsWalletInfo { + final String? id; + final String name; + final int balanceMsat; + + const LnBitsWalletInfo({ + required this.id, + required this.name, + required this.balanceMsat, + }); +} + +class LnBitsApiException implements Exception { + final int statusCode; + final String message; + + const LnBitsApiException(this.statusCode, this.message); + + @override + String toString() => 'LNbits API error ($statusCode): $message'; +} + +/// Direct LNbits REST API wallet provider. +class LnBitsWalletProvider implements WalletProvider { + final http.Client _client; + + LnBitsWalletProvider([http.Client? client]) + : _client = client ?? http.Client(); + + @override + WalletType get type => WalletType.LNBITS; + + static String normalizeUrl(String value) { + final trimmed = value.trim().replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.tryParse(trimmed); + if (uri == null || + !uri.hasScheme || + (uri.scheme != 'https' && uri.scheme != 'http') || + uri.host.isEmpty || + uri.hasQuery || + uri.hasFragment) { + throw const FormatException('Enter a valid LNbits HTTP or HTTPS URL'); + } + return uri.toString().replaceAll(RegExp(r'/+$'), ''); + } + + /// Verifies credentials and returns wallet details without storing anything. + static Future probe({ + required String lnbitsUrl, + required String adminKey, + Duration timeout = const Duration(seconds: 10), + http.Client? client, + }) async { + final ownedClient = client == null ? http.Client() : null; + final effectiveClient = client ?? ownedClient!; + try { + final provider = LnBitsWalletProvider(effectiveClient); + return await provider._getWalletInfo( + lnbitsUrl: normalizeUrl(lnbitsUrl), + adminKey: _validateAdminKey(adminKey), + timeout: timeout, + ); + } finally { + ownedClient?.close(); + } + } + + @override + Wallet createWallet({ + required String id, + required String name, + required Set supportedUnits, + required Map metadata, + }) { + return LnBitsWallet( + id: id, + name: name, + supportedUnits: supportedUnits, + lnbitsUrl: normalizeUrl( + metadata[LnBitsWallet.urlMetadataKey]?.toString() ?? '', + ), + adminKey: _validateAdminKey( + metadata[LnBitsWallet.adminKeyMetadataKey]?.toString() ?? '', + ), + remoteWalletId: + metadata[LnBitsWallet.remoteWalletIdMetadataKey]?.toString(), + metadata: metadata, + ); + } + + @override + Future initialize(Wallet wallet) async { + final lnbitsWallet = _asLnBitsWallet(wallet); + final info = await _getWalletInfo( + lnbitsUrl: lnbitsWallet.lnbitsUrl, + adminKey: lnbitsWallet.adminKey, + ); + if (info.id == lnbitsWallet.remoteWalletId) return null; + return LnBitsWallet( + id: lnbitsWallet.id, + name: lnbitsWallet.name, + supportedUnits: lnbitsWallet.supportedUnits, + lnbitsUrl: lnbitsWallet.lnbitsUrl, + adminKey: lnbitsWallet.adminKey, + remoteWalletId: info.id, + metadata: lnbitsWallet.metadata, + ); + } + + @override + Future removeWallet(Wallet wallet) async {} + + @override + Stream> getBalances(Wallet wallet) { + final lnbitsWallet = _asLnBitsWallet(wallet); + return Stream.fromFuture( + _getWalletInfo( + lnbitsUrl: lnbitsWallet.lnbitsUrl, + adminKey: lnbitsWallet.adminKey, + ).then( + (info) => [ + WalletBalance( + walletId: wallet.id, + unit: 'sat', + amount: info.balanceMsat ~/ 1000, + ), + ], + ), + ); + } + + @override + Stream> getPendingTransactions(Wallet wallet) { + final lnbitsWallet = _asLnBitsWallet(wallet); + return Stream.fromFuture( + _getPayments(lnbitsWallet).then( + (items) => items.where((item) => item.state.isPending).toList(), + ), + ); + } + + @override + Stream> getRecentTransactions(Wallet wallet) { + final lnbitsWallet = _asLnBitsWallet(wallet); + return Stream.fromFuture( + _getPayments(lnbitsWallet).then( + (items) => items.where((item) => item.state.isDone).toList(), + ), + ); + } + + @override + Future send( + Wallet wallet, + String invoice, { + Duration? timeout, + }) async { + final result = await _pay( + _asLnBitsWallet(wallet), + invoice, + timeout: timeout, + ); + return PayInvoiceResponse( + resultType: 'pay_invoice', + preimage: result.preimage, + feesPaid: result.feesPaid, + ); + } + + @override + Future receive(Wallet wallet, int amountSats) async { + if (amountSats <= 0) { + throw ArgumentError.value(amountSats, 'amountSats', 'Must be positive'); + } + final response = await _requestJson( + _asLnBitsWallet(wallet), + 'POST', + '/api/v1/payments', + body: {'out': false, 'amount': amountSats, 'unit': 'sat', 'memo': ''}, + ); + final invoice = response['payment_request']?.toString(); + if (invoice == null || invoice.isEmpty) { + throw const FormatException('LNbits returned no payment request'); + } + return invoice; + } + + @override + Future payBip321( + Wallet wallet, { + required String payment, + int? amountMsat, + String? payerNote, + Map? metadata, + Duration? timeout, + }) async { + if (payerNote?.isNotEmpty == true) { + throw UnsupportedError( + 'LNbits BOLT11 payments do not support payer notes'); + } + final invoice = Bip321.getBolt11(payment); + final invoiceAmount = Bip321.getBolt11AmountMsat(invoice); + if (invoiceAmount != null && + amountMsat != null && + invoiceAmount != amountMsat) { + throw ArgumentError( + 'BIP-321 amount $amountMsat msats conflicts with ' + 'the BOLT11 invoice amount $invoiceAmount msats', + ); + } + final result = await _pay( + _asLnBitsWallet(wallet), + invoice, + timeout: timeout, + ); + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return PayResponse( + resultType: 'pay', + transactionId: result.paymentHash, + state: 'settled', + instructionType: 'bolt11', + amountMsat: invoiceAmount ?? amountMsat ?? 0, + feesPaid: result.feesPaid, + paymentHash: result.paymentHash, + preimage: result.preimage, + createdAt: result.createdAt ?? now, + settledAt: result.createdAt ?? now, + ); + } + + @override + Future receiveBip321( + Wallet wallet, { + int? amountMsat, + String? description, + Map? metadata, + Duration? timeout, + }) async { + if (amountMsat == null || amountMsat <= 0 || amountMsat % 1000 != 0) { + throw ArgumentError.value( + amountMsat, + 'amountMsat', + 'LNbits requires a positive whole-satoshi amount', + ); + } + final response = await _requestJson( + _asLnBitsWallet(wallet), + 'POST', + '/api/v1/payments', + body: { + 'out': false, + 'amount': amountMsat ~/ 1000, + 'unit': 'sat', + 'memo': description ?? '', + }, + timeout: timeout, + ); + final invoice = response['payment_request']?.toString(); + if (invoice == null || invoice.isEmpty) { + throw const FormatException('LNbits returned no payment request'); + } + return ReceiveResponse( + resultType: 'receive', + bip321: Bip321.fromBolt11(invoice), + transactionId: response['payment_hash']?.toString(), + ); + } + + @override + Stream> get discoveredWallets => Stream.value(const []); + + Future _getWalletInfo({ + required String lnbitsUrl, + required String adminKey, + Duration? timeout, + }) async { + final wallet = LnBitsWallet( + id: 'probe', + name: 'LNbits', + supportedUnits: const {'sat'}, + lnbitsUrl: lnbitsUrl, + adminKey: adminKey, + ); + final response = await _requestJson( + wallet, + 'GET', + '/api/v1/wallet', + timeout: timeout, + ); + final name = response['name']?.toString().trim(); + return LnBitsWalletInfo( + id: response['id']?.toString(), + name: name?.isNotEmpty == true ? name! : 'LNbits', + balanceMsat: _asInt(response['balance']) ?? 0, + ); + } + + Future<_LnBitsPaymentResult> _pay( + LnBitsWallet wallet, + String invoice, { + Duration? timeout, + }) async { + final response = await _requestJson( + wallet, + 'POST', + '/api/v1/payments', + body: {'out': true, 'bolt11': invoice}, + timeout: timeout, + ); + final paymentHash = response['payment_hash']?.toString(); + if (paymentHash == null || paymentHash.isEmpty) { + throw const FormatException('LNbits returned no payment hash'); + } + return _LnBitsPaymentResult( + paymentHash: paymentHash, + preimage: response['preimage']?.toString(), + feesPaid: (_asInt(response['fee']) ?? 0).abs(), + createdAt: _asInt(response['time']), + ); + } + + Future> _getPayments(LnBitsWallet wallet) async { + final response = await _request( + wallet, + 'GET', + '/api/v1/payments', + ); + final decoded = jsonDecode(response.body); + if (decoded is! List) { + throw const FormatException('Invalid LNbits payments'); + } + return decoded.whereType().map((raw) { + final payment = Map.from(raw); + final amountMsat = _asInt(payment['amount']) ?? 0; + return LnurlWalletTransaction( + id: payment['payment_hash']?.toString() ?? '', + walletId: wallet.id, + changeAmount: amountMsat ~/ 1000, + unit: payment['unit']?.toString() ?? 'sat', + walletType: WalletType.LNBITS, + state: _paymentState(payment), + completionMsg: payment['memo']?.toString(), + transactionDate: _asInt(payment['time']), + initiatedDate: _asInt(payment['created_at']) ?? _asInt(payment['time']), + metadata: payment, + ); + }).toList(); + } + + Future> _requestJson( + LnBitsWallet wallet, + String method, + String path, { + Map? body, + Duration? timeout, + }) async { + final response = await _request( + wallet, + method, + path, + body: body, + timeout: timeout, + ); + final decoded = jsonDecode(response.body); + if (decoded is! Map) throw const FormatException('Invalid LNbits response'); + return Map.from(decoded); + } + + Future _request( + LnBitsWallet wallet, + String method, + String path, { + Map? body, + Duration? timeout, + }) async { + final base = Uri.parse(wallet.lnbitsUrl); + final uri = base.replace( + path: '${base.path.replaceAll(RegExp(r'/+$'), '')}$path', + query: null, + fragment: null, + ); + final request = http.Request(method, uri) + ..headers.addAll({ + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-Api-Key': wallet.adminKey, + }); + if (body != null) request.body = jsonEncode(body); + final future = _client.send(request).then(http.Response.fromStream); + final response = await future.timeout( + timeout ?? const Duration(seconds: 15), + ); + if (response.statusCode < 200 || response.statusCode >= 300) { + var message = response.body; + try { + final decoded = jsonDecode(response.body); + if (decoded is Map && decoded['detail'] != null) { + message = decoded['detail'].toString(); + } + } catch (_) {} + throw LnBitsApiException(response.statusCode, message); + } + return response; + } + + static LnBitsWallet _asLnBitsWallet(Wallet wallet) { + if (wallet is! LnBitsWallet) { + throw ArgumentError('Expected an LnBitsWallet'); + } + return wallet; + } + + static String _validateAdminKey(String value) { + final key = value.trim(); + if (key.isEmpty) { + throw const FormatException('LNbits Admin Key is required'); + } + return key; + } + + static int? _asInt(dynamic value) { + if (value is int) return value; + if (value is num) return value.toInt(); + return int.tryParse(value?.toString() ?? ''); + } + + static WalletTransactionState _paymentState(Map payment) { + if (payment['pending'] == true || payment['status'] == 'pending') { + return WalletTransactionState.pending; + } + if (payment['status'] == 'failed') return WalletTransactionState.failed; + return WalletTransactionState.completed; + } +} + +class _LnBitsPaymentResult { + final String paymentHash; + final String? preimage; + final int feesPaid; + final int? createdAt; + + const _LnBitsPaymentResult({ + required this.paymentHash, + required this.preimage, + required this.feesPaid, + required this.createdAt, + }); +} diff --git a/packages/ndk/lib/domain_layer/entities/wallet/wallet_factory.dart b/packages/ndk/lib/domain_layer/entities/wallet/wallet_factory.dart index c58164e50..208cbe2ee 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/wallet_factory.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/wallet_factory.dart @@ -20,11 +20,15 @@ export 'providers/lnurl/lnurl_wallet_provider.dart'; export 'providers/bolt12/bolt12_wallet.dart'; export 'providers/bolt12/bolt12_wallet_provider.dart'; +export 'providers/lnbits/lnbits_wallet.dart'; +export 'providers/lnbits/lnbits_wallet_provider.dart'; + // Then: imports needed for WalletFactory import 'providers/cashu/cashu_wallet.dart'; import 'providers/nwc/nwc_wallet.dart'; import 'providers/lnurl/lnurl_wallet.dart'; import 'providers/bolt12/bolt12_wallet.dart'; +import 'providers/lnbits/lnbits_wallet.dart'; import 'wallet.dart'; import 'wallet_type.dart'; @@ -70,6 +74,13 @@ class WalletFactory { supportedUnits: supportedUnits, metadata: metadata, ); + case WalletType.LNBITS: + return LnBitsWallet.fromStorage( + id: id, + name: name, + supportedUnits: supportedUnits, + metadata: metadata, + ); } } } diff --git a/packages/ndk/lib/domain_layer/entities/wallet/wallet_transaction.dart b/packages/ndk/lib/domain_layer/entities/wallet/wallet_transaction.dart index cb0085c15..c05537dea 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/wallet_transaction.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/wallet_transaction.dart @@ -102,6 +102,7 @@ abstract class WalletTransaction { ); case WalletType.LNURL: case WalletType.BOLT12: + case WalletType.LNBITS: return LnurlWalletTransaction( id: id, walletId: walletId, diff --git a/packages/ndk/lib/domain_layer/entities/wallet/wallet_type.dart b/packages/ndk/lib/domain_layer/entities/wallet/wallet_type.dart index 512a2f7a5..241e7b5cb 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/wallet_type.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/wallet_type.dart @@ -6,7 +6,9 @@ enum WalletType { // ignore: constant_identifier_names LNURL('lnurl'), // ignore: constant_identifier_names - BOLT12('bolt12'); + BOLT12('bolt12'), + // ignore: constant_identifier_names + LNBITS('lnbits'); final String value; diff --git a/packages/ndk/lib/presentation_layer/init.dart b/packages/ndk/lib/presentation_layer/init.dart index e76aecc00..6c6b4356d 100644 --- a/packages/ndk/lib/presentation_layer/init.dart +++ b/packages/ndk/lib/presentation_layer/init.dart @@ -20,6 +20,7 @@ import '../domain_layer/entities/wallet/providers/cashu/cashu_wallet_provider.da import '../domain_layer/entities/wallet/providers/nwc/nwc_wallet_provider.dart'; import '../domain_layer/entities/wallet/providers/lnurl/lnurl_wallet_provider.dart'; import '../domain_layer/entities/wallet/providers/bolt12/bolt12_wallet_provider.dart'; +import '../domain_layer/entities/wallet/providers/lnbits/lnbits_wallet_provider.dart'; import '../domain_layer/repositories/blossom.dart'; import '../domain_layer/repositories/cashu_repo.dart'; import '../domain_layer/repositories/lnurl_transport.dart'; @@ -319,6 +320,7 @@ class Initialization { // Create LNURL wallet provider after lnurl is initialized final lnurlProvider = LnurlWalletProvider(lnurl); const bolt12Provider = Bolt12WalletProvider(); + final lnbitsProvider = LnBitsWalletProvider(); zaps = Zaps(requests: requests, nwc: nwc, lnurl: lnurl); @@ -365,7 +367,13 @@ class Initialization { connectivity = Connectivy(relayManager); wallets = Wallets( - providers: [cashuProvider, nwcProvider, lnurlProvider, bolt12Provider], + providers: [ + cashuProvider, + nwcProvider, + lnurlProvider, + bolt12Provider, + lnbitsProvider, + ], repository: _ndkConfig.walletsRepo!, ); proofOfWork = ProofOfWork(); diff --git a/packages/ndk/test/entities/lnbits_wallet_test.dart b/packages/ndk/test/entities/lnbits_wallet_test.dart new file mode 100644 index 000000000..14b3af5fc --- /dev/null +++ b/packages/ndk/test/entities/lnbits_wallet_test.dart @@ -0,0 +1,217 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:ndk/entities.dart'; +import 'package:test/test.dart'; + +void main() { + const url = 'https://lnbits.example/base'; + const key = 'admin-secret'; + + LnBitsWallet wallet() => LnBitsWallet( + id: 'local-id', + name: 'My LNbits', + supportedUnits: const {'sat'}, + lnbitsUrl: url, + adminKey: key, + ); + + group('LnBitsWallet', () { + test('normalizes URL and round-trips storage metadata', () { + final provider = LnBitsWalletProvider(MockClient((_) async { + throw StateError('not called'); + })); + final created = provider.createWallet( + id: 'local-id', + name: 'My LNbits', + supportedUnits: const {'sat'}, + metadata: const { + LnBitsWallet.urlMetadataKey: '$url/', + LnBitsWallet.adminKeyMetadataKey: ' $key ', + LnBitsWallet.remoteWalletIdMetadataKey: 'remote-id', + }, + ) as LnBitsWallet; + + expect(created.lnbitsUrl, url); + expect(created.adminKey, key); + expect(created.remoteWalletId, 'remote-id'); + expect(created.canSend, isTrue); + expect(created.canReceive, isTrue); + + final restored = LnBitsWallet.fromStorage( + id: created.id, + name: created.name, + supportedUnits: created.supportedUnits, + metadata: created.toMetadata(), + ); + expect(restored.lnbitsUrl, url); + expect(restored.adminKey, key); + expect(restored.remoteWalletId, 'remote-id'); + expect( + WalletFactory.fromStorage( + id: created.id, + name: created.name, + type: WalletType.LNBITS, + supportedUnits: created.supportedUnits, + metadata: created.metadata, + ), + isA()); + }); + + test('rejects invalid URLs and missing credentials', () { + expect( + () => LnBitsWalletProvider.normalizeUrl('lnbits.example'), + throwsFormatException, + ); + expect( + () => LnBitsWalletProvider.normalizeUrl('https://lnbits.example?q=1'), + throwsFormatException, + ); + expect( + () => LnBitsWallet.fromStorage( + id: 'id', + name: 'name', + supportedUnits: const {'sat'}, + metadata: const {}, + ), + throwsArgumentError, + ); + }); + }); + + group('LnBitsWalletProvider', () { + test('probes credentials, initializes remote id, and reads balance', + () async { + final requests = []; + final client = MockClient((request) async { + requests.add(request); + return http.Response( + jsonEncode({'id': 'remote-id', 'name': 'Remote', 'balance': 12345}), + 200, + ); + }); + final provider = LnBitsWalletProvider(client); + + final info = await LnBitsWalletProvider.probe( + lnbitsUrl: '$url/', + adminKey: key, + client: client, + ); + expect(info.id, 'remote-id'); + expect(info.name, 'Remote'); + expect(info.balanceMsat, 12345); + + final initialized = await provider.initialize(wallet()) as LnBitsWallet; + expect(initialized.remoteWalletId, 'remote-id'); + final balances = await provider.getBalances(initialized).first; + expect(balances.single.amount, 12); + expect(balances.single.unit, 'sat'); + expect(requests, hasLength(3)); + expect( + requests + .every((request) => request.url.path == '/base/api/v1/wallet'), + isTrue); + expect(requests.every((request) => request.headers['X-Api-Key'] == key), + isTrue); + }); + + test('creates and pays invoices with Admin Key authentication', () async { + final requests = []; + final provider = LnBitsWalletProvider(MockClient((request) async { + requests.add(request); + final body = jsonDecode(request.body) as Map; + if (body['out'] == true) { + return http.Response( + jsonEncode({ + 'payment_hash': 'hash', + 'preimage': 'preimage', + 'fee': -21, + 'time': 10, + }), + 201, + ); + } + return http.Response( + jsonEncode({ + 'payment_hash': 'incoming-hash', + 'payment_request': 'lnbc1invoice', + }), + 201, + ); + })); + + final paid = await provider.send(wallet(), 'lnbc1outgoing'); + expect(paid.preimage, 'preimage'); + expect(paid.feesPaid, 21); + + final invoice = await provider.receive(wallet(), 42); + expect(invoice, 'lnbc1invoice'); + final received = await provider.receiveBip321( + wallet(), + amountMsat: 42000, + description: 'memo', + ); + expect(received.bip321, 'bitcoin:?lightning=lnbc1invoice'); + expect(received.transactionId, 'incoming-hash'); + expect(requests, hasLength(3)); + expect(requests.every((request) => request.method == 'POST'), isTrue); + expect( + requests + .every((request) => request.url.path == '/base/api/v1/payments'), + isTrue); + }); + + test('maps pending and completed LNbits transactions', () async { + final provider = LnBitsWalletProvider(MockClient((_) async { + return http.Response( + jsonEncode([ + { + 'payment_hash': 'pending', + 'amount': -2000, + 'pending': true, + 'time': 10, + 'memo': 'Paying', + }, + { + 'payment_hash': 'settled', + 'amount': 3000, + 'pending': false, + 'status': 'success', + 'time': 20, + }, + ]), + 200, + ); + })); + + final pending = await provider.getPendingTransactions(wallet()).first; + final recent = await provider.getRecentTransactions(wallet()).first; + + expect(pending.single.id, 'pending'); + expect(pending.single.changeAmount, -2); + expect(pending.single.state, WalletTransactionState.pending); + expect(recent.single.id, 'settled'); + expect(recent.single.changeAmount, 3); + expect(recent.single.walletType, WalletType.LNBITS); + }); + + test('reports API detail without exposing Admin Key', () async { + final provider = LnBitsWalletProvider(MockClient((_) async { + return http.Response(jsonEncode({'detail': 'Invalid key'}), 401); + })); + + await expectLater( + provider.getBalances(wallet()).first, + throwsA( + isA() + .having((error) => error.statusCode, 'statusCode', 401) + .having((error) => error.toString(), 'message', + contains('Invalid key')) + .having( + (error) => error.toString(), 'secret', isNot(contains(key))), + ), + ); + }); + }); +} diff --git a/packages/ndk_flutter/assets/images/lnbits.svg b/packages/ndk_flutter/assets/images/lnbits.svg new file mode 100644 index 000000000..a8ce41e8c --- /dev/null +++ b/packages/ndk_flutter/assets/images/lnbits.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/ndk_flutter/lib/l10n/app_de.arb b/packages/ndk_flutter/lib/l10n/app_de.arb index 63f3e9b48..5f6be1940 100644 --- a/packages/ndk_flutter/lib/l10n/app_de.arb +++ b/packages/ndk_flutter/lib/l10n/app_de.arb @@ -1,4 +1,11 @@ { + "lnbitsWalletOption": "LNbits", + "lnbitsConnectionInstructions": "Wähle in LNbits die Wallet aus, die du verbinden möchtest, öffne sie, klicke auf API-Dokumentation und kopiere den Admin Key. Füge ihn unten ein:", + "lnbitsAdminKey": "LNbits Admin Key", + "lnbitsUrl": "LNbits-URL", + "lnbitsCredentialsRequired": "Gib den LNbits Admin Key und die URL ein.", + "lnbitsWalletAdded": "LNbits-Wallet erfolgreich hinzugefügt", + "walletDetailWalletId": "Wallet-ID", "saveBackupToFile": "Backup in Datei speichern", "backupSavedToFile": "Backup in Datei gespeichert", "restoreFromFile": "Aus Datei wiederherstellen", diff --git a/packages/ndk_flutter/lib/l10n/app_en.arb b/packages/ndk_flutter/lib/l10n/app_en.arb index c71631fbb..f094ed9ba 100644 --- a/packages/ndk_flutter/lib/l10n/app_en.arb +++ b/packages/ndk_flutter/lib/l10n/app_en.arb @@ -1,4 +1,11 @@ { + "lnbitsWalletOption": "LNbits", + "lnbitsConnectionInstructions": "In LNbits, choose the wallet you want to connect, open it, click API docs, and copy the Admin Key. Paste it below:", + "lnbitsAdminKey": "LNbits Admin Key", + "lnbitsUrl": "LNbits URL", + "lnbitsCredentialsRequired": "Enter both the LNbits Admin Key and URL.", + "lnbitsWalletAdded": "LNbits wallet added successfully", + "walletDetailWalletId": "Wallet ID", "saveBackupToFile": "Save backup to file", "backupSavedToFile": "Backup saved to file", "restoreFromFile": "Restore from file", diff --git a/packages/ndk_flutter/lib/l10n/app_es.arb b/packages/ndk_flutter/lib/l10n/app_es.arb index d112a184a..ada3c9fe4 100644 --- a/packages/ndk_flutter/lib/l10n/app_es.arb +++ b/packages/ndk_flutter/lib/l10n/app_es.arb @@ -1,4 +1,11 @@ { + "lnbitsWalletOption": "LNbits", + "lnbitsConnectionInstructions": "En LNbits, elige la cartera que quieres conectar, ábrela, pulsa Documentación de API y copia la clave de administrador. Pégala abajo:", + "lnbitsAdminKey": "Clave de administrador de LNbits", + "lnbitsUrl": "URL de LNbits", + "lnbitsCredentialsRequired": "Introduce la clave de administrador y la URL de LNbits.", + "lnbitsWalletAdded": "Cartera LNbits añadida correctamente", + "walletDetailWalletId": "ID de cartera", "saveBackupToFile": "Guardar copia en un archivo", "backupSavedToFile": "Copia guardada en un archivo", "restoreFromFile": "Restaurar desde un archivo", diff --git a/packages/ndk_flutter/lib/l10n/app_fi.arb b/packages/ndk_flutter/lib/l10n/app_fi.arb index 41e0cfc5d..1b372d0ec 100644 --- a/packages/ndk_flutter/lib/l10n/app_fi.arb +++ b/packages/ndk_flutter/lib/l10n/app_fi.arb @@ -1,4 +1,11 @@ { + "lnbitsWalletOption": "LNbits", + "lnbitsConnectionInstructions": "Valitse LNbitsissä yhdistettävä lompakko, avaa se, napsauta API-ohjeita ja kopioi ylläpitäjän avain. Liitä se alle:", + "lnbitsAdminKey": "LNbits-ylläpitäjän avain", + "lnbitsUrl": "LNbits-URL", + "lnbitsCredentialsRequired": "Anna LNbits-ylläpitäjän avain ja URL-osoite.", + "lnbitsWalletAdded": "LNbits-lompakko lisättiin", + "walletDetailWalletId": "Lompakon tunnus", "saveBackupToFile": "Tallenna varmuuskopio tiedostoon", "backupSavedToFile": "Varmuuskopio tallennettu tiedostoon", "restoreFromFile": "Palauta tiedostosta", diff --git a/packages/ndk_flutter/lib/l10n/app_fr.arb b/packages/ndk_flutter/lib/l10n/app_fr.arb index f397cb378..bd87d2f06 100644 --- a/packages/ndk_flutter/lib/l10n/app_fr.arb +++ b/packages/ndk_flutter/lib/l10n/app_fr.arb @@ -1,4 +1,11 @@ { + "lnbitsWalletOption": "LNbits", + "lnbitsConnectionInstructions": "Dans LNbits, choisissez le portefeuille à connecter, ouvrez-le, cliquez sur Documentation API et copiez la clé administrateur. Collez-la ci-dessous :", + "lnbitsAdminKey": "Clé administrateur LNbits", + "lnbitsUrl": "URL LNbits", + "lnbitsCredentialsRequired": "Saisissez la clé administrateur et l’URL LNbits.", + "lnbitsWalletAdded": "Portefeuille LNbits ajouté", + "walletDetailWalletId": "Identifiant du portefeuille", "saveBackupToFile": "Enregistrer la sauvegarde dans un fichier", "backupSavedToFile": "Sauvegarde enregistrée dans un fichier", "restoreFromFile": "Restaurer depuis un fichier", diff --git a/packages/ndk_flutter/lib/l10n/app_it.arb b/packages/ndk_flutter/lib/l10n/app_it.arb index 197c71117..60d82580a 100644 --- a/packages/ndk_flutter/lib/l10n/app_it.arb +++ b/packages/ndk_flutter/lib/l10n/app_it.arb @@ -1,4 +1,11 @@ { + "lnbitsWalletOption": "LNbits", + "lnbitsConnectionInstructions": "In LNbits, scegli il portafoglio da collegare, aprilo, fai clic su Documentazione API e copia la chiave amministratore. Incollala qui sotto:", + "lnbitsAdminKey": "Chiave amministratore LNbits", + "lnbitsUrl": "URL LNbits", + "lnbitsCredentialsRequired": "Inserisci la chiave amministratore e l’URL LNbits.", + "lnbitsWalletAdded": "Portafoglio LNbits aggiunto", + "walletDetailWalletId": "ID portafoglio", "saveBackupToFile": "Salva backup su file", "backupSavedToFile": "Backup salvato su file", "restoreFromFile": "Ripristina da file", diff --git a/packages/ndk_flutter/lib/l10n/app_ja.arb b/packages/ndk_flutter/lib/l10n/app_ja.arb index fe7554bd3..9b092e203 100644 --- a/packages/ndk_flutter/lib/l10n/app_ja.arb +++ b/packages/ndk_flutter/lib/l10n/app_ja.arb @@ -1,4 +1,11 @@ { + "lnbitsWalletOption": "LNbits", + "lnbitsConnectionInstructions": "LNbitsで接続するウォレットを選んで開き、APIドキュメントを押して管理者キーをコピーしてください。下に貼り付けます:", + "lnbitsAdminKey": "LNbits管理者キー", + "lnbitsUrl": "LNbits URL", + "lnbitsCredentialsRequired": "LNbits管理者キーとURLを入力してください。", + "lnbitsWalletAdded": "LNbitsウォレットを追加しました", + "walletDetailWalletId": "ウォレットID", "saveBackupToFile": "バックアップをファイルに保存", "backupSavedToFile": "バックアップをファイルに保存しました", "restoreFromFile": "ファイルから復元", diff --git a/packages/ndk_flutter/lib/l10n/app_localizations.dart b/packages/ndk_flutter/lib/l10n/app_localizations.dart index 71d17848e..d32cf7192 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations.dart @@ -119,6 +119,48 @@ abstract class AppLocalizations { Locale('zh'), ]; + /// No description provided for @lnbitsWalletOption. + /// + /// In en, this message translates to: + /// **'LNbits'** + String get lnbitsWalletOption; + + /// No description provided for @lnbitsConnectionInstructions. + /// + /// In en, this message translates to: + /// **'In LNbits, choose the wallet you want to connect, open it, click API docs, and copy the Admin Key. Paste it below:'** + String get lnbitsConnectionInstructions; + + /// No description provided for @lnbitsAdminKey. + /// + /// In en, this message translates to: + /// **'LNbits Admin Key'** + String get lnbitsAdminKey; + + /// No description provided for @lnbitsUrl. + /// + /// In en, this message translates to: + /// **'LNbits URL'** + String get lnbitsUrl; + + /// No description provided for @lnbitsCredentialsRequired. + /// + /// In en, this message translates to: + /// **'Enter both the LNbits Admin Key and URL.'** + String get lnbitsCredentialsRequired; + + /// No description provided for @lnbitsWalletAdded. + /// + /// In en, this message translates to: + /// **'LNbits wallet added successfully'** + String get lnbitsWalletAdded; + + /// No description provided for @walletDetailWalletId. + /// + /// In en, this message translates to: + /// **'Wallet ID'** + String get walletDetailWalletId; + /// No description provided for @saveBackupToFile. /// /// In en, this message translates to: diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_de.dart b/packages/ndk_flutter/lib/l10n/app_localizations_de.dart index 3a4d349aa..213f221e2 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_de.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_de.dart @@ -8,6 +8,29 @@ import 'app_localizations.dart'; class AppLocalizationsDe extends AppLocalizations { AppLocalizationsDe([String locale = 'de']) : super(locale); + @override + String get lnbitsWalletOption => 'LNbits'; + + @override + String get lnbitsConnectionInstructions => + 'Wähle in LNbits die Wallet aus, die du verbinden möchtest, öffne sie, klicke auf API-Dokumentation und kopiere den Admin Key. Füge ihn unten ein:'; + + @override + String get lnbitsAdminKey => 'LNbits Admin Key'; + + @override + String get lnbitsUrl => 'LNbits-URL'; + + @override + String get lnbitsCredentialsRequired => + 'Gib den LNbits Admin Key und die URL ein.'; + + @override + String get lnbitsWalletAdded => 'LNbits-Wallet erfolgreich hinzugefügt'; + + @override + String get walletDetailWalletId => 'Wallet-ID'; + @override String get saveBackupToFile => 'Backup in Datei speichern'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_en.dart b/packages/ndk_flutter/lib/l10n/app_localizations_en.dart index a8daa84b1..ff3e5261a 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_en.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_en.dart @@ -8,6 +8,29 @@ import 'app_localizations.dart'; class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); + @override + String get lnbitsWalletOption => 'LNbits'; + + @override + String get lnbitsConnectionInstructions => + 'In LNbits, choose the wallet you want to connect, open it, click API docs, and copy the Admin Key. Paste it below:'; + + @override + String get lnbitsAdminKey => 'LNbits Admin Key'; + + @override + String get lnbitsUrl => 'LNbits URL'; + + @override + String get lnbitsCredentialsRequired => + 'Enter both the LNbits Admin Key and URL.'; + + @override + String get lnbitsWalletAdded => 'LNbits wallet added successfully'; + + @override + String get walletDetailWalletId => 'Wallet ID'; + @override String get saveBackupToFile => 'Save backup to file'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_es.dart b/packages/ndk_flutter/lib/l10n/app_localizations_es.dart index c5e5a92a3..228aff721 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_es.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_es.dart @@ -8,6 +8,29 @@ import 'app_localizations.dart'; class AppLocalizationsEs extends AppLocalizations { AppLocalizationsEs([String locale = 'es']) : super(locale); + @override + String get lnbitsWalletOption => 'LNbits'; + + @override + String get lnbitsConnectionInstructions => + 'En LNbits, elige la cartera que quieres conectar, ábrela, pulsa Documentación de API y copia la clave de administrador. Pégala abajo:'; + + @override + String get lnbitsAdminKey => 'Clave de administrador de LNbits'; + + @override + String get lnbitsUrl => 'URL de LNbits'; + + @override + String get lnbitsCredentialsRequired => + 'Introduce la clave de administrador y la URL de LNbits.'; + + @override + String get lnbitsWalletAdded => 'Cartera LNbits añadida correctamente'; + + @override + String get walletDetailWalletId => 'ID de cartera'; + @override String get saveBackupToFile => 'Guardar copia en un archivo'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart b/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart index 082a74f88..a4f6ed856 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart @@ -8,6 +8,29 @@ import 'app_localizations.dart'; class AppLocalizationsFi extends AppLocalizations { AppLocalizationsFi([String locale = 'fi']) : super(locale); + @override + String get lnbitsWalletOption => 'LNbits'; + + @override + String get lnbitsConnectionInstructions => + 'Valitse LNbitsissä yhdistettävä lompakko, avaa se, napsauta API-ohjeita ja kopioi ylläpitäjän avain. Liitä se alle:'; + + @override + String get lnbitsAdminKey => 'LNbits-ylläpitäjän avain'; + + @override + String get lnbitsUrl => 'LNbits-URL'; + + @override + String get lnbitsCredentialsRequired => + 'Anna LNbits-ylläpitäjän avain ja URL-osoite.'; + + @override + String get lnbitsWalletAdded => 'LNbits-lompakko lisättiin'; + + @override + String get walletDetailWalletId => 'Lompakon tunnus'; + @override String get saveBackupToFile => 'Tallenna varmuuskopio tiedostoon'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart b/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart index 1f5063dc0..fa3871a99 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart @@ -8,6 +8,29 @@ import 'app_localizations.dart'; class AppLocalizationsFr extends AppLocalizations { AppLocalizationsFr([String locale = 'fr']) : super(locale); + @override + String get lnbitsWalletOption => 'LNbits'; + + @override + String get lnbitsConnectionInstructions => + 'Dans LNbits, choisissez le portefeuille à connecter, ouvrez-le, cliquez sur Documentation API et copiez la clé administrateur. Collez-la ci-dessous :'; + + @override + String get lnbitsAdminKey => 'Clé administrateur LNbits'; + + @override + String get lnbitsUrl => 'URL LNbits'; + + @override + String get lnbitsCredentialsRequired => + 'Saisissez la clé administrateur et l’URL LNbits.'; + + @override + String get lnbitsWalletAdded => 'Portefeuille LNbits ajouté'; + + @override + String get walletDetailWalletId => 'Identifiant du portefeuille'; + @override String get saveBackupToFile => 'Enregistrer la sauvegarde dans un fichier'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_it.dart b/packages/ndk_flutter/lib/l10n/app_localizations_it.dart index ef898d806..35ea5c270 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_it.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_it.dart @@ -8,6 +8,29 @@ import 'app_localizations.dart'; class AppLocalizationsIt extends AppLocalizations { AppLocalizationsIt([String locale = 'it']) : super(locale); + @override + String get lnbitsWalletOption => 'LNbits'; + + @override + String get lnbitsConnectionInstructions => + 'In LNbits, scegli il portafoglio da collegare, aprilo, fai clic su Documentazione API e copia la chiave amministratore. Incollala qui sotto:'; + + @override + String get lnbitsAdminKey => 'Chiave amministratore LNbits'; + + @override + String get lnbitsUrl => 'URL LNbits'; + + @override + String get lnbitsCredentialsRequired => + 'Inserisci la chiave amministratore e l’URL LNbits.'; + + @override + String get lnbitsWalletAdded => 'Portafoglio LNbits aggiunto'; + + @override + String get walletDetailWalletId => 'ID portafoglio'; + @override String get saveBackupToFile => 'Salva backup su file'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart b/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart index bbd4d98aa..20702ae41 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart @@ -8,6 +8,28 @@ import 'app_localizations.dart'; class AppLocalizationsJa extends AppLocalizations { AppLocalizationsJa([String locale = 'ja']) : super(locale); + @override + String get lnbitsWalletOption => 'LNbits'; + + @override + String get lnbitsConnectionInstructions => + 'LNbitsで接続するウォレットを選んで開き、APIドキュメントを押して管理者キーをコピーしてください。下に貼り付けます:'; + + @override + String get lnbitsAdminKey => 'LNbits管理者キー'; + + @override + String get lnbitsUrl => 'LNbits URL'; + + @override + String get lnbitsCredentialsRequired => 'LNbits管理者キーとURLを入力してください。'; + + @override + String get lnbitsWalletAdded => 'LNbitsウォレットを追加しました'; + + @override + String get walletDetailWalletId => 'ウォレットID'; + @override String get saveBackupToFile => 'バックアップをファイルに保存'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart b/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart index 96d1fb081..37490ce69 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart @@ -8,6 +8,29 @@ import 'app_localizations.dart'; class AppLocalizationsPl extends AppLocalizations { AppLocalizationsPl([String locale = 'pl']) : super(locale); + @override + String get lnbitsWalletOption => 'LNbits'; + + @override + String get lnbitsConnectionInstructions => + 'W LNbits wybierz portfel, który chcesz połączyć, otwórz go, kliknij Dokumentacja API i skopiuj klucz administratora. Wklej go poniżej:'; + + @override + String get lnbitsAdminKey => 'Klucz administratora LNbits'; + + @override + String get lnbitsUrl => 'Adres URL LNbits'; + + @override + String get lnbitsCredentialsRequired => + 'Wprowadź klucz administratora i adres URL LNbits.'; + + @override + String get lnbitsWalletAdded => 'Portfel LNbits został dodany'; + + @override + String get walletDetailWalletId => 'Identyfikator portfela'; + @override String get saveBackupToFile => 'Zapisz kopię do pliku'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart b/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart index 22377c264..dbb3acd3f 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart @@ -8,6 +8,29 @@ import 'app_localizations.dart'; class AppLocalizationsPt extends AppLocalizations { AppLocalizationsPt([String locale = 'pt']) : super(locale); + @override + String get lnbitsWalletOption => 'LNbits'; + + @override + String get lnbitsConnectionInstructions => + 'No LNbits, escolha a carteira que pretende ligar, abra-a, clique em Documentação da API e copie a chave de administrador. Cole-a abaixo:'; + + @override + String get lnbitsAdminKey => 'Chave de administrador LNbits'; + + @override + String get lnbitsUrl => 'URL do LNbits'; + + @override + String get lnbitsCredentialsRequired => + 'Introduza a chave de administrador e o URL do LNbits.'; + + @override + String get lnbitsWalletAdded => 'Carteira LNbits adicionada'; + + @override + String get walletDetailWalletId => 'ID da carteira'; + @override String get saveBackupToFile => 'Guardar cópia num ficheiro'; @@ -1462,6 +1485,29 @@ class AppLocalizationsPt extends AppLocalizations { class AppLocalizationsPtBr extends AppLocalizationsPt { AppLocalizationsPtBr() : super('pt_BR'); + @override + String get lnbitsWalletOption => 'LNbits'; + + @override + String get lnbitsConnectionInstructions => + 'No LNbits, escolha a carteira que deseja conectar, abra-a, clique em Documentação da API e copie a chave de administrador. Cole-a abaixo:'; + + @override + String get lnbitsAdminKey => 'Chave de administrador LNbits'; + + @override + String get lnbitsUrl => 'URL do LNbits'; + + @override + String get lnbitsCredentialsRequired => + 'Insira a chave de administrador e a URL do LNbits.'; + + @override + String get lnbitsWalletAdded => 'Carteira LNbits adicionada'; + + @override + String get walletDetailWalletId => 'ID da carteira'; + @override String get saveBackupToFile => 'Salvar backup em arquivo'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart b/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart index 32c4f2121..f239c64b6 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart @@ -8,6 +8,29 @@ import 'app_localizations.dart'; class AppLocalizationsRu extends AppLocalizations { AppLocalizationsRu([String locale = 'ru']) : super(locale); + @override + String get lnbitsWalletOption => 'LNbits'; + + @override + String get lnbitsConnectionInstructions => + 'В LNbits выберите кошелёк для подключения, откройте его, нажмите «Документация API» и скопируйте ключ администратора. Вставьте его ниже:'; + + @override + String get lnbitsAdminKey => 'Ключ администратора LNbits'; + + @override + String get lnbitsUrl => 'URL LNbits'; + + @override + String get lnbitsCredentialsRequired => + 'Введите ключ администратора и URL LNbits.'; + + @override + String get lnbitsWalletAdded => 'Кошелёк LNbits добавлен'; + + @override + String get walletDetailWalletId => 'ID кошелька'; + @override String get saveBackupToFile => 'Сохранить резервную копию в файл'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart b/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart index 9becf3461..c26e8dcb9 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart @@ -8,6 +8,28 @@ import 'app_localizations.dart'; class AppLocalizationsSk extends AppLocalizations { AppLocalizationsSk([String locale = 'sk']) : super(locale); + @override + String get lnbitsWalletOption => 'LNbits'; + + @override + String get lnbitsConnectionInstructions => + 'V LNbits vyberte peňaženku, ktorú chcete pripojiť, otvorte ju, kliknite na Dokumentáciu API a skopírujte kľúč správcu. Vložte ho nižšie:'; + + @override + String get lnbitsAdminKey => 'Kľúč správcu LNbits'; + + @override + String get lnbitsUrl => 'URL LNbits'; + + @override + String get lnbitsCredentialsRequired => 'Zadajte kľúč správcu aj URL LNbits.'; + + @override + String get lnbitsWalletAdded => 'Peňaženka LNbits bola pridaná'; + + @override + String get walletDetailWalletId => 'ID peňaženky'; + @override String get saveBackupToFile => 'Uložiť zálohu do súboru'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart b/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart index cb7661c76..e14508d99 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart @@ -8,6 +8,28 @@ import 'app_localizations.dart'; class AppLocalizationsZh extends AppLocalizations { AppLocalizationsZh([String locale = 'zh']) : super(locale); + @override + String get lnbitsWalletOption => 'LNbits'; + + @override + String get lnbitsConnectionInstructions => + '在 LNbits 中选择并打开要连接的钱包,点击 API 文档并复制管理员密钥。粘贴到下方:'; + + @override + String get lnbitsAdminKey => 'LNbits 管理员密钥'; + + @override + String get lnbitsUrl => 'LNbits URL'; + + @override + String get lnbitsCredentialsRequired => '请输入 LNbits 管理员密钥和 URL。'; + + @override + String get lnbitsWalletAdded => 'LNbits 钱包已添加'; + + @override + String get walletDetailWalletId => '钱包 ID'; + @override String get saveBackupToFile => '将备份保存到文件'; diff --git a/packages/ndk_flutter/lib/l10n/app_pl.arb b/packages/ndk_flutter/lib/l10n/app_pl.arb index 0148f0ae6..d1cd8daf8 100644 --- a/packages/ndk_flutter/lib/l10n/app_pl.arb +++ b/packages/ndk_flutter/lib/l10n/app_pl.arb @@ -1,4 +1,11 @@ { + "lnbitsWalletOption": "LNbits", + "lnbitsConnectionInstructions": "W LNbits wybierz portfel, który chcesz połączyć, otwórz go, kliknij Dokumentacja API i skopiuj klucz administratora. Wklej go poniżej:", + "lnbitsAdminKey": "Klucz administratora LNbits", + "lnbitsUrl": "Adres URL LNbits", + "lnbitsCredentialsRequired": "Wprowadź klucz administratora i adres URL LNbits.", + "lnbitsWalletAdded": "Portfel LNbits został dodany", + "walletDetailWalletId": "Identyfikator portfela", "saveBackupToFile": "Zapisz kopię do pliku", "backupSavedToFile": "Kopia zapisana do pliku", "restoreFromFile": "Przywróć z pliku", diff --git a/packages/ndk_flutter/lib/l10n/app_pt.arb b/packages/ndk_flutter/lib/l10n/app_pt.arb index 9ab19a72d..403594c58 100644 --- a/packages/ndk_flutter/lib/l10n/app_pt.arb +++ b/packages/ndk_flutter/lib/l10n/app_pt.arb @@ -1,4 +1,11 @@ { + "lnbitsWalletOption": "LNbits", + "lnbitsConnectionInstructions": "No LNbits, escolha a carteira que pretende ligar, abra-a, clique em Documentação da API e copie a chave de administrador. Cole-a abaixo:", + "lnbitsAdminKey": "Chave de administrador LNbits", + "lnbitsUrl": "URL do LNbits", + "lnbitsCredentialsRequired": "Introduza a chave de administrador e o URL do LNbits.", + "lnbitsWalletAdded": "Carteira LNbits adicionada", + "walletDetailWalletId": "ID da carteira", "saveBackupToFile": "Guardar cópia num ficheiro", "backupSavedToFile": "Cópia guardada num ficheiro", "restoreFromFile": "Restaurar de um ficheiro", diff --git a/packages/ndk_flutter/lib/l10n/app_pt_BR.arb b/packages/ndk_flutter/lib/l10n/app_pt_BR.arb index 2ecad81d9..5199170b8 100644 --- a/packages/ndk_flutter/lib/l10n/app_pt_BR.arb +++ b/packages/ndk_flutter/lib/l10n/app_pt_BR.arb @@ -1,4 +1,11 @@ { + "lnbitsWalletOption": "LNbits", + "lnbitsConnectionInstructions": "No LNbits, escolha a carteira que deseja conectar, abra-a, clique em Documentação da API e copie a chave de administrador. Cole-a abaixo:", + "lnbitsAdminKey": "Chave de administrador LNbits", + "lnbitsUrl": "URL do LNbits", + "lnbitsCredentialsRequired": "Insira a chave de administrador e a URL do LNbits.", + "lnbitsWalletAdded": "Carteira LNbits adicionada", + "walletDetailWalletId": "ID da carteira", "saveBackupToFile": "Salvar backup em arquivo", "backupSavedToFile": "Backup salvo em arquivo", "restoreFromFile": "Restaurar de arquivo", diff --git a/packages/ndk_flutter/lib/l10n/app_ru.arb b/packages/ndk_flutter/lib/l10n/app_ru.arb index e8a416f8d..f16acac7f 100644 --- a/packages/ndk_flutter/lib/l10n/app_ru.arb +++ b/packages/ndk_flutter/lib/l10n/app_ru.arb @@ -1,4 +1,11 @@ { + "lnbitsWalletOption": "LNbits", + "lnbitsConnectionInstructions": "В LNbits выберите кошелёк для подключения, откройте его, нажмите «Документация API» и скопируйте ключ администратора. Вставьте его ниже:", + "lnbitsAdminKey": "Ключ администратора LNbits", + "lnbitsUrl": "URL LNbits", + "lnbitsCredentialsRequired": "Введите ключ администратора и URL LNbits.", + "lnbitsWalletAdded": "Кошелёк LNbits добавлен", + "walletDetailWalletId": "ID кошелька", "saveBackupToFile": "Сохранить резервную копию в файл", "backupSavedToFile": "Резервная копия сохранена в файл", "restoreFromFile": "Восстановить из файла", diff --git a/packages/ndk_flutter/lib/l10n/app_sk.arb b/packages/ndk_flutter/lib/l10n/app_sk.arb index 7f97b7573..1f20e71b8 100644 --- a/packages/ndk_flutter/lib/l10n/app_sk.arb +++ b/packages/ndk_flutter/lib/l10n/app_sk.arb @@ -1,4 +1,11 @@ { + "lnbitsWalletOption": "LNbits", + "lnbitsConnectionInstructions": "V LNbits vyberte peňaženku, ktorú chcete pripojiť, otvorte ju, kliknite na Dokumentáciu API a skopírujte kľúč správcu. Vložte ho nižšie:", + "lnbitsAdminKey": "Kľúč správcu LNbits", + "lnbitsUrl": "URL LNbits", + "lnbitsCredentialsRequired": "Zadajte kľúč správcu aj URL LNbits.", + "lnbitsWalletAdded": "Peňaženka LNbits bola pridaná", + "walletDetailWalletId": "ID peňaženky", "saveBackupToFile": "Uložiť zálohu do súboru", "backupSavedToFile": "Záloha bola uložená do súboru", "restoreFromFile": "Obnoviť zo súboru", diff --git a/packages/ndk_flutter/lib/l10n/app_zh.arb b/packages/ndk_flutter/lib/l10n/app_zh.arb index 616284e52..688814e96 100644 --- a/packages/ndk_flutter/lib/l10n/app_zh.arb +++ b/packages/ndk_flutter/lib/l10n/app_zh.arb @@ -1,4 +1,11 @@ { + "lnbitsWalletOption": "LNbits", + "lnbitsConnectionInstructions": "在 LNbits 中选择并打开要连接的钱包,点击 API 文档并复制管理员密钥。粘贴到下方:", + "lnbitsAdminKey": "LNbits 管理员密钥", + "lnbitsUrl": "LNbits URL", + "lnbitsCredentialsRequired": "请输入 LNbits 管理员密钥和 URL。", + "lnbitsWalletAdded": "LNbits 钱包已添加", + "walletDetailWalletId": "钱包 ID", "saveBackupToFile": "将备份保存到文件", "backupSavedToFile": "备份已保存到文件", "restoreFromFile": "从文件恢复", diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_add_wallet_dialogs.dart b/packages/ndk_flutter/lib/widgets/wallets/n_add_wallet_dialogs.dart index 288eb0176..e9f1825ae 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_add_wallet_dialogs.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_add_wallet_dialogs.dart @@ -35,20 +35,45 @@ class WalletInputScanResult { final bool manuallyEntered; final WalletInputOrigin origin; final CashuMintSuggestion? cashuMintSuggestion; + final LnBitsConnectionInput? lnBitsConnection; const WalletInputScanResult.value( this.value, { this.manuallyEntered = false, this.origin = WalletInputOrigin.scanner, this.cashuMintSuggestion, - }) : connectionStarted = false; + }) : connectionStarted = false, + lnBitsConnection = null; + + const WalletInputScanResult.lnBits(LnBitsConnectionInput connection) + : lnBitsConnection = connection, + value = null, + connectionStarted = false, + manuallyEntered = true, + origin = WalletInputOrigin.walletChooser, + cashuMintSuggestion = null; const WalletInputScanResult.connectionStarted() : value = null, connectionStarted = true, manuallyEntered = false, origin = WalletInputOrigin.walletChooser, - cashuMintSuggestion = null; + cashuMintSuggestion = null, + lnBitsConnection = null; +} + +class LnBitsConnectionInput { + final String url; + final String adminKey; + final String? walletName; + final String? remoteWalletId; + + const LnBitsConnectionInput({ + required this.url, + required this.adminKey, + this.walletName, + this.remoteWalletId, + }); } /// Wallet connection choice displayed inside a host-provided scanner screen. @@ -83,6 +108,8 @@ class WalletInputScannerConfiguration { final Future> Function() discoverCashuMints; final Future Function(CashuMintSuggestion suggestion) enrichCashuMint; + final Future Function(LnBitsConnectionInput input)? + validateLnBitsConnection; final bool openWalletChooserInitially; final bool openCashuMintChooserInitially; @@ -95,6 +122,7 @@ class WalletInputScannerConfiguration { required this.cancelPendingConnection, required this.discoverCashuMints, required this.enrichCashuMint, + this.validateLnBitsConnection, this.openWalletChooserInitially = false, this.openCashuMintChooserInitially = false, }); @@ -162,7 +190,7 @@ class NwcConnectionOption { } /// Wallet input categories recognized by the unified add-wallet flow. -enum WalletInputKind { nwc, bolt12, lightningAddress, cashuMint } +enum WalletInputKind { nwc, bolt12, lightningAddress, cashuMint, lnBits } /// Classifies locally recognizable wallet input without performing network I/O. WalletInputKind? classifyWalletInput(String input) { @@ -1848,6 +1876,7 @@ class _WalletInputPreview { final CashuMintSuggestion? cashuMintSuggestion; final Bolt12ResolvedOffer? resolvedOffer; final CashuMintInfo? mintInfo; + final LnBitsConnectionInput? lnBitsConnection; const _WalletInputPreview({ required this.input, @@ -1860,6 +1889,7 @@ class _WalletInputPreview { this.cashuMintSuggestion, this.resolvedOffer, this.mintInfo, + this.lnBitsConnection, }); } @@ -1873,6 +1903,8 @@ class _WalletPreviewDetail { class _AddWalletDialogState extends State<_AddWalletDialog> { final _inputController = TextEditingController(); final _walletNameController = TextEditingController(); + final _lnBitsUrlController = TextEditingController(); + final _lnBitsAdminKeyController = TextEditingController(); WalletInputKind? _inputKind; String? _errorMessage; bool _isAdding = false; @@ -1895,6 +1927,8 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { void dispose() { _inputController.dispose(); _walletNameController.dispose(); + _lnBitsUrlController.dispose(); + _lnBitsAdminKeyController.dispose(); super.dispose(); } @@ -1925,6 +1959,10 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { Navigator.of(context).pop(true); return; } + if (result.lnBitsConnection case final connection?) { + await _prepareLnBitsPreview(connection); + return; + } if (result.value != null) { await _preparePreview( result.value!, @@ -1941,6 +1979,66 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { await _preparePreview(value); } + Future _prepareLnBitsPreview(LnBitsConnectionInput connection) async { + final l10n = AppLocalizations.of(context)!; + setState(() { + _isResolvingDetails = true; + _errorMessage = null; + }); + try { + final validated = connection.walletName == null + ? await _validateLnBitsConnection(connection) + : connection; + if (!mounted) return; + final preview = _WalletInputPreview( + input: validated.url, + manuallyEntered: true, + origin: WalletInputOrigin.walletChooser, + detectedKind: WalletInputKind.lnBits, + walletType: WalletType.LNBITS, + name: validated.walletName!, + lnBitsConnection: validated, + details: [ + _WalletPreviewDetail(l10n.walletDetailType, l10n.lnbitsWalletOption), + _WalletPreviewDetail(l10n.lnbitsUrl, validated.url), + _WalletPreviewDetail(l10n.lnbitsAdminKey, l10n.walletSecretHidden), + if (validated.remoteWalletId?.isNotEmpty == true) + _WalletPreviewDetail( + l10n.walletDetailWalletId, + validated.remoteWalletId!, + ), + ], + ); + setState(() { + _preview = preview; + _lnBitsUrlController.text = validated.url; + _lnBitsAdminKeyController.text = validated.adminKey; + _walletNameController.text = preview.name; + }); + } catch (error) { + if (mounted) setState(() => _errorMessage = error.toString()); + } finally { + if (mounted) setState(() => _isResolvingDetails = false); + } + } + + Future _validateLnBitsConnection( + LnBitsConnectionInput connection, + ) async { + final normalizedUrl = LnBitsWalletProvider.normalizeUrl(connection.url); + final adminKey = connection.adminKey.trim(); + final info = await LnBitsWalletProvider.probe( + lnbitsUrl: normalizedUrl, + adminKey: adminKey, + ); + return LnBitsConnectionInput( + url: normalizedUrl, + adminKey: adminKey, + walletName: info.name, + remoteWalletId: info.id, + ); + } + void _cancelPreview() { final origin = _preview?.origin ?? WalletInputOrigin.scanner; setState(() { @@ -2014,6 +2112,7 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { widget.nwcWalletAuthCoordinator.cancelPendingConnection, discoverCashuMints: _discoverCashuMints, enrichCashuMint: _enrichCashuMint, + validateLnBitsConnection: _validateLnBitsConnection, openWalletChooserInitially: openWalletChooserInitially, openCashuMintChooserInitially: openCashuMintChooserInitially, ); @@ -2345,6 +2444,8 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { ), ], ); + case WalletInputKind.lnBits: + throw StateError('LNbits uses structured connection details'); } } @@ -2417,8 +2518,17 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { var preview = _preview; if (preview == null) return; - final input = _normalizeWalletInput(_inputController.text); - final kind = classifyWalletInput(input); + final isLnBits = preview.walletType == WalletType.LNBITS; + late final String input; + try { + input = isLnBits + ? LnBitsWalletProvider.normalizeUrl(_lnBitsUrlController.text) + : _normalizeWalletInput(_inputController.text); + } catch (error) { + setState(() => _errorMessage = error.toString()); + return; + } + final kind = isLnBits ? WalletInputKind.lnBits : classifyWalletInput(input); if (kind == null) { setState(() { _errorMessage = AppLocalizations.of(context)!.unsupportedWalletInput; @@ -2433,7 +2543,24 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { }); try { - if (input != preview.input || kind != preview.detectedKind) { + if (isLnBits) { + final connection = await _validateLnBitsConnection( + LnBitsConnectionInput( + url: input, + adminKey: _lnBitsAdminKeyController.text.trim(), + ), + ); + preview = _WalletInputPreview( + input: input, + manuallyEntered: true, + detectedKind: WalletInputKind.lnBits, + walletType: WalletType.LNBITS, + name: preview.name, + details: preview.details, + origin: preview.origin, + lnBitsConnection: connection, + ); + } else if (input != preview.input || kind != preview.detectedKind) { preview = await _resolvePreview( input, kind, @@ -2488,6 +2615,21 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { walletName: walletName, mintInfo: preview.mintInfo, ); + case WalletType.LNBITS: + final connection = preview.lnBitsConnection!; + final wallet = widget.ndkFlutter.ndk.wallets.createWallet( + id: 'lnbits-${DateTime.now().microsecondsSinceEpoch}', + name: walletName, + type: WalletType.LNBITS, + supportedUnits: const {'sat'}, + metadata: { + LnBitsWallet.urlMetadataKey: connection.url, + LnBitsWallet.adminKeyMetadataKey: connection.adminKey, + LnBitsWallet.remoteWalletIdMetadataKey: ?connection.remoteWalletId, + }, + ); + await widget.ndkFlutter.ndk.wallets.addWallet(wallet); + return wallet; } } @@ -2550,6 +2692,7 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { WalletType.BOLT12 => l10n.bolt12WalletAdded, WalletType.LNURL => l10n.lnurlWalletAdded, WalletType.CASHU => l10n.cashuWalletAdded, + WalletType.LNBITS => l10n.lnbitsWalletAdded, }; ScaffoldMessenger.of(widget.parentContext).showSnackBar( SnackBar(content: Text(message), backgroundColor: Colors.green), @@ -2562,6 +2705,7 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { WalletInputKind.bolt12 => l10n.bolt12WalletTypeTitle, WalletInputKind.lightningAddress => l10n.lightningAddressInputType, WalletInputKind.cashuMint => l10n.cashuWalletTypeTitle, + WalletInputKind.lnBits => l10n.lnbitsWalletOption, }; } @@ -2639,6 +2783,8 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { case WalletType.CASHU: await showAddCashuWalletDialog(widget.parentContext, widget.ndkFlutter); return; + case WalletType.LNBITS: + return; } } @@ -2654,6 +2800,7 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { WalletType.BOLT12 => Icons.bolt, WalletType.LNURL => Icons.alternate_email, WalletType.CASHU => Icons.toll_outlined, + WalletType.LNBITS => Icons.bolt, }; final mintIconUrl = preview.cashuMintSuggestion?.iconUrl?.trim().isNotEmpty == true @@ -2677,28 +2824,30 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { crossAxisAlignment: CrossAxisAlignment.start, children: [ Center( - child: Container( - width: 64, - height: 64, - decoration: BoxDecoration( - color: colors.primaryContainer, - borderRadius: BorderRadius.circular(18), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(18), - child: - preview.walletType == WalletType.CASHU && - mintIconUrl?.isNotEmpty == true - ? Image.network( - mintIconUrl!, - width: 64, - height: 64, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => fallbackIcon, - ) - : fallbackIcon, - ), - ), + child: preview.walletType == WalletType.LNBITS + ? const NLnBitsIcon(size: 64) + : Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: colors.primaryContainer, + borderRadius: BorderRadius.circular(18), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(18), + child: + preview.walletType == WalletType.CASHU && + mintIconUrl?.isNotEmpty == true + ? Image.network( + mintIconUrl!, + width: 64, + height: 64, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => fallbackIcon, + ) + : fallbackIcon, + ), + ), ), const SizedBox(height: 20), Text( @@ -2713,7 +2862,31 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { ), ), const SizedBox(height: 22), - if (preview.manuallyEntered) ...[ + if (preview.walletType == WalletType.LNBITS) ...[ + TextField( + controller: _lnBitsAdminKeyController, + enabled: !_isAdding, + obscureText: true, + enableSuggestions: false, + autocorrect: false, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.lnbitsAdminKey, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _lnBitsUrlController, + enabled: !_isAdding, + keyboardType: TextInputType.url, + autocorrect: false, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.lnbitsUrl, + ), + ), + const SizedBox(height: 16), + ] else if (preview.manuallyEntered) ...[ TextField( controller: _inputController, onChanged: _onInputChanged, diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_lnbits_icon.dart b/packages/ndk_flutter/lib/widgets/wallets/n_lnbits_icon.dart new file mode 100644 index 000000000..2497c6460 --- /dev/null +++ b/packages/ndk_flutter/lib/widgets/wallets/n_lnbits_icon.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +class NLnBitsIcon extends StatelessWidget { + final double size; + final bool showShadow; + + const NLnBitsIcon({super.key, this.size = 64, this.showShadow = false}); + + @override + Widget build(BuildContext context) { + final dark = Theme.of(context).brightness == Brightness.dark; + return Container( + width: size, + height: size, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: const Color(0xFF673AB7), + borderRadius: BorderRadius.circular(size / 4), + boxShadow: showShadow + ? [ + BoxShadow( + color: dark + ? Colors.white.withValues(alpha: 0.15) + : Colors.black.withValues(alpha: 0.25), + offset: const Offset(0, 10), + blurRadius: 15, + ), + ] + : null, + ), + child: SvgPicture.asset( + 'assets/images/lnbits.svg', + package: 'ndk_flutter', + width: size, + height: size, + ), + ); + } +} 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 2c9bb9d8e..dfd854dae 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_actions.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_actions.dart @@ -93,6 +93,7 @@ class _NWalletActionsState extends State final bool isCashu = selectedWallet is CashuWallet; final bool isNwc = selectedWallet is NwcWallet; final bool isBolt12 = selectedWallet is Bolt12Wallet; + final bool isLnBits = selectedWallet is LnBitsWallet; final bool canSend = selectedWallet.canSend; final bool canReceive = selectedWallet.canReceive; final bool condensed = widget.condensed; @@ -121,6 +122,8 @@ class _NWalletActionsState extends State ) else if (isBolt12) const Icon(Icons.electric_bolt, color: Colors.green) + else if (isLnBits) + const NLnBitsIcon(size: 24) else const Icon(Icons.bolt, color: Colors.purple), const SizedBox(width: 8), @@ -131,6 +134,8 @@ class _NWalletActionsState extends State ? l10n.nwcWallet : isBolt12 ? l10n.bolt12Wallet + : isLnBits + ? l10n.lnbitsWalletOption : l10n.lnurlWallet, style: Theme.of(context).textTheme.titleMedium, ), 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 881daa2f9..3f5f90cb7 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card.dart @@ -253,6 +253,7 @@ class _NWalletCardState extends State final bool isNwc = widget.wallet is NwcWallet; final bool isLnurl = widget.wallet is LnurlWallet; final bool isBolt12 = widget.wallet is Bolt12Wallet; + final bool isLnBits = widget.wallet is LnBitsWallet; final nwcPermissions = isNwc ? _nwcPermissions(widget.wallet as NwcWallet) : const {}; @@ -275,6 +276,8 @@ class _NWalletCardState extends State walletName = (widget.wallet as LnurlWallet).name; } else if (isBolt12) { walletName = (widget.wallet as Bolt12Wallet).name; + } else if (isLnBits) { + walletName = (widget.wallet as LnBitsWallet).name; } else { walletName = l10n.unknownWalletType; } @@ -300,6 +303,11 @@ class _NWalletCardState extends State (bolt12Wallet.hasBlindedPaths ? l10n.bolt12PrivateOfferSubtitle : l10n.bolt12WalletSubtitle); + } else if (isLnBits) { + subtitle = (widget.wallet as LnBitsWallet).lnbitsUrl.replaceFirst( + RegExp(r'^https?://'), + '', + ); } else { subtitle = ''; } @@ -324,6 +332,7 @@ class _NWalletCardState extends State isNwc, isLnurl, isBolt12, + isLnBits, ); } } @@ -349,6 +358,10 @@ class _NWalletCardState extends State iconConfig = widget.bolt12Icon ?? const WalletIconConfig(); defaultAssetName = null; fallbackIcon = Icons.electric_bolt; + } else if (isLnBits) { + iconConfig = const WalletIconConfig(); + defaultAssetName = null; + fallbackIcon = Icons.bolt; } else { iconConfig = const WalletIconConfig(); defaultAssetName = 'wallet.png'; @@ -363,6 +376,8 @@ class _NWalletCardState extends State wallet: widget.wallet as CashuWallet, size: iconConfig.iconSize, ) + : isLnBits + ? NLnBitsIcon(size: iconConfig.iconSize) : defaultAssetName != null ? Image.asset( 'assets/images/$defaultAssetName', @@ -386,7 +401,12 @@ class _NWalletCardState extends State // Build background widget final Widget backgroundWidget = iconConfig.backgroundWidget ?? - (defaultAssetName != null + (isLnBits + ? Opacity( + opacity: iconConfig.backgroundOpacity, + child: NLnBitsIcon(size: iconConfig.backgroundSize), + ) + : defaultAssetName != null ? Image.asset( 'assets/images/$defaultAssetName', package: 'ndk_flutter', @@ -841,6 +861,7 @@ class _NWalletCardState extends State bool isNwc, bool isLnurl, bool isBolt12, + bool isLnBits, ) { if (isCashu) { return [const Color(0xFF7F38CA), const Color(0xFF9B5AD8)]; @@ -853,6 +874,8 @@ class _NWalletCardState extends State return [const Color(0xFFFFB300), const Color(0xFFFFC107)]; } else if (isBolt12) { return [const Color(0xFF1B5E20), const Color(0xFF43A047)]; + } else if (isLnBits) { + return [const Color(0xFF512DA8), const Color(0xFF7E57C2)]; } else { return [Colors.grey[700]!, Colors.grey[400]!]; } diff --git a/packages/ndk_flutter/lib/widgets/widgets.dart b/packages/ndk_flutter/lib/widgets/widgets.dart index 705d9a72b..e41d5019b 100644 --- a/packages/ndk_flutter/lib/widgets/widgets.dart +++ b/packages/ndk_flutter/lib/widgets/widgets.dart @@ -14,3 +14,4 @@ export 'wallets/n_pending_transactions.dart'; export 'wallets/n_recent_transactions.dart'; export 'wallets/n_wallet_actions.dart'; export 'wallets/n_add_wallet_dialogs.dart'; +export 'wallets/n_lnbits_icon.dart'; diff --git a/packages/ndk_flutter/pubspec.yaml b/packages/ndk_flutter/pubspec.yaml index 479e31beb..ebc4b881a 100644 --- a/packages/ndk_flutter/pubspec.yaml +++ b/packages/ndk_flutter/pubspec.yaml @@ -29,6 +29,7 @@ dependencies: flutter_localizations: sdk: flutter flutter_secure_storage: ">=8.0.0 <11.0.0" + flutter_svg: ^2.2.1 file_picker: ^10.3.10 http: ">=1.0.0 <2.0.0" intl: ^0.20.2 diff --git a/packages/sample-app/lib/nwc_qr_scanner.dart b/packages/sample-app/lib/nwc_qr_scanner.dart index 8fd00b7f1..ee6115ba9 100644 --- a/packages/sample-app/lib/nwc_qr_scanner.dart +++ b/packages/sample-app/lib/nwc_qr_scanner.dart @@ -469,6 +469,7 @@ class _ManualWalletInputDialogState extends State<_ManualWalletInputDialog> { WalletInputKind.bolt12 => l10n.bolt12WalletTypeTitle, WalletInputKind.lightningAddress => l10n.lightningAddressInputType, WalletInputKind.cashuMint => l10n.cashuWalletTypeTitle, + WalletInputKind.lnBits => l10n.lnbitsWalletOption, }; } @@ -678,6 +679,18 @@ class _WalletChooserDialogState extends State<_WalletChooserDialog> { if (result != null && context.mounted) Navigator.of(context).pop(result); } + Future _openLnBits(BuildContext context) async { + final result = await showDialog( + context: context, + builder: (_) => _LnBitsConnectionDialog( + validate: configuration.validateLnBitsConnection, + ), + ); + if (result != null && context.mounted) { + Navigator.of(context).pop(WalletInputScanResult.lnBits(result)); + } + } + void _returnToScanner(BuildContext context) { Navigator.of(context).pop(); } @@ -747,6 +760,11 @@ class _WalletChooserDialogState extends State<_WalletChooserDialog> { icon: const _NwcIcon(), onTap: () => _manualNwc(context), ), + _WalletGridTile( + label: l10n.lnbitsWalletOption, + icon: const _LnBitsIcon(), + onTap: () => _openLnBits(context), + ), ], ), ), @@ -754,6 +772,140 @@ class _WalletChooserDialogState extends State<_WalletChooserDialog> { } } +class _LnBitsConnectionDialog extends StatefulWidget { + final Future Function(LnBitsConnectionInput input)? + validate; + + const _LnBitsConnectionDialog({this.validate}); + + @override + State<_LnBitsConnectionDialog> createState() => + _LnBitsConnectionDialogState(); +} + +class _LnBitsConnectionDialogState extends State<_LnBitsConnectionDialog> { + final _adminKeyController = TextEditingController(); + final _urlController = TextEditingController(text: 'https://'); + bool _showAdminKey = false; + bool _isValidating = false; + String? _error; + + @override + void dispose() { + _adminKeyController.dispose(); + _urlController.dispose(); + super.dispose(); + } + + Future _continue() async { + final l10n = ndk_l10n.AppLocalizations.of(context)!; + final adminKey = _adminKeyController.text.trim(); + final url = _urlController.text.trim(); + if (adminKey.isEmpty || url.isEmpty || url == 'https://') { + setState(() => _error = l10n.lnbitsCredentialsRequired); + return; + } + final input = LnBitsConnectionInput(url: url, adminKey: adminKey); + final validate = widget.validate; + if (validate == null) { + Navigator.of(context).pop(input); + return; + } + setState(() { + _isValidating = true; + _error = null; + }); + try { + final validated = await validate(input); + if (mounted) Navigator.of(context).pop(validated); + } catch (error) { + if (mounted) setState(() => _error = error.toString()); + } finally { + if (mounted) setState(() => _isValidating = false); + } + } + + @override + Widget build(BuildContext context) { + final l10n = ndk_l10n.AppLocalizations.of(context)!; + return AlertDialog( + title: Row( + children: [ + const _LnBitsIcon(), + const SizedBox(width: 16), + Expanded(child: Text(l10n.lnbitsWalletOption)), + IconButton( + onPressed: _isValidating ? null : () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l10n.lnbitsConnectionInstructions), + const SizedBox(height: 20), + TextField( + controller: _adminKeyController, + enabled: !_isValidating, + obscureText: !_showAdminKey, + enableSuggestions: false, + autocorrect: false, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.lnbitsAdminKey, + suffixIcon: IconButton( + onPressed: () => setState(() { + _showAdminKey = !_showAdminKey; + }), + icon: Icon( + _showAdminKey ? Icons.visibility_off : Icons.visibility, + ), + ), + ), + ), + const SizedBox(height: 16), + TextField( + controller: _urlController, + enabled: !_isValidating, + keyboardType: TextInputType.url, + autocorrect: false, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.lnbitsUrl, + ), + ), + if (_error != null) ...[ + const SizedBox(height: 12), + Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: _isValidating ? null : () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: _isValidating ? null : _continue, + child: _isValidating + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.reviewWallet), + ), + ], + ); + } +} + class _CashuMintChooserDialog extends StatefulWidget { final WalletInputScannerConfiguration configuration; @@ -1189,6 +1341,23 @@ class _NwcIcon extends StatelessWidget { } } +class _LnBitsIcon extends StatelessWidget { + const _LnBitsIcon(); + + @override + Widget build(BuildContext context) { + return _BrandIconFrame( + backgroundColor: const Color(0xFF673AB7), + child: SvgPicture.asset( + 'assets/images/lnbits.svg', + package: 'ndk_flutter', + width: 64, + height: 64, + ), + ); + } +} + class _BrandIconFrame extends StatelessWidget { final Color backgroundColor; final Widget child; diff --git a/packages/sample-app/pubspec.lock b/packages/sample-app/pubspec.lock index f0f2c234f..d7b7f4d37 100644 --- a/packages/sample-app/pubspec.lock +++ b/packages/sample-app/pubspec.lock @@ -587,21 +587,21 @@ packages: path: "../ndk" relative: true source: path - version: "0.9.3" + version: "0.10.0-dev.2" ndk_drift: dependency: "direct main" description: path: "../drift" relative: true source: path - version: "0.1.1-dev.5" + version: "0.1.1-dev.10" ndk_flutter: dependency: "direct main" description: path: "../ndk_flutter" relative: true source: path - version: "0.9.0-dev.3" + version: "0.9.0-dev.8" nested: dependency: transitive description: From ade5401542aed6cb5ea5ae3b5d77307fe9f98676 Mon Sep 17 00:00:00 2001 From: fmar Date: Sat, 12 Sep 2026 13:15:00 +0200 Subject: [PATCH 19/22] feat(sample): support desktop QR scanning --- packages/sample-app/lib/nwc_qr_scanner.dart | 267 +++++++++++++++++- .../flutter/generated_plugin_registrant.cc | 8 + .../linux/flutter/generated_plugins.cmake | 3 + .../Flutter/GeneratedPluginRegistrant.swift | 4 + packages/sample-app/pubspec.lock | 148 +++++++++- packages/sample-app/pubspec.yaml | 1 + .../flutter/generated_plugin_registrant.cc | 6 + .../windows/flutter/generated_plugins.cmake | 3 + 8 files changed, 427 insertions(+), 13 deletions(-) diff --git a/packages/sample-app/lib/nwc_qr_scanner.dart b/packages/sample-app/lib/nwc_qr_scanner.dart index ee6115ba9..48df27b29 100644 --- a/packages/sample-app/lib/nwc_qr_scanner.dart +++ b/packages/sample-app/lib/nwc_qr_scanner.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_webrtc_zxing/flutter_webrtc_zxing.dart' as webrtc_zxing; import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:ndk_flutter/ndk_flutter.dart'; import 'package:ndk_flutter/l10n/app_localizations.dart' as ndk_l10n; @@ -31,18 +32,24 @@ class _WalletQrScannerDialog extends StatefulWidget { class _WalletQrScannerDialogState extends State<_WalletQrScannerDialog> { MobileScannerController? _scannerController; bool _hasScanned = false; + bool _cameraPaused = false; String? _errorMessage; bool _closingAfterSuccess = false; - bool get _hasCamera => - !kIsWeb && - (defaultTargetPlatform == TargetPlatform.android || - defaultTargetPlatform == TargetPlatform.iOS); + bool get _usesMobileScanner => + kIsWeb || + defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS; + + bool get _usesWebRtcScanner => + !kIsWeb && defaultTargetPlatform == TargetPlatform.linux; + + bool get _hasCamera => _usesMobileScanner || _usesWebRtcScanner; @override void initState() { super.initState(); - if (_hasCamera) { + if (_usesMobileScanner) { _scannerController = MobileScannerController( detectionSpeed: DetectionSpeed.normal, facing: CameraFacing.back, @@ -95,6 +102,20 @@ class _WalletQrScannerDialogState extends State<_WalletQrScannerDialog> { } } + void _onWebRtcBarcodeDetected(webrtc_zxing.Code code) { + if (_hasScanned) return; + final rawValue = code.text?.trim(); + if (rawValue == null || rawValue.isEmpty) return; + + setState(() => _hasScanned = true); + Navigator.of(context).pop(WalletInputScanResult.value(rawValue)); + } + + void _onWebRtcScannerCreated(Object? _, Exception? error) { + if (!mounted || error == null) return; + setState(() => _errorMessage = error.toString()); + } + Future _pasteFromClipboard() async { final clipboardData = await Clipboard.getData(Clipboard.kTextPlain); if (!mounted) return; @@ -105,6 +126,11 @@ class _WalletQrScannerDialogState extends State<_WalletQrScannerDialog> { String initialValue = '', bool nwcOnly = false, }) async { + if (_usesWebRtcScanner && mounted) { + setState(() => _cameraPaused = true); + } + await _scannerController?.stop(); + if (!mounted) return; final result = await showDialog<_ManualWalletInputResult>( context: context, builder: (_) => _ManualWalletInputDialog( @@ -115,7 +141,12 @@ class _WalletQrScannerDialogState extends State<_WalletQrScannerDialog> { ), ); - if (!mounted || result == null) return; + if (!mounted) return; + if (result == null) { + if (_usesWebRtcScanner) setState(() => _cameraPaused = false); + await _scannerController?.start(); + return; + } if (result.connectionStarted) { Navigator.of(context).pop( const WalletInputScanResult.connectionStarted(), @@ -130,6 +161,9 @@ class _WalletQrScannerDialogState extends State<_WalletQrScannerDialog> { } Future _chooseWallet() async { + if (_usesWebRtcScanner && mounted) { + setState(() => _cameraPaused = true); + } await _scannerController?.stop(); if (!mounted) return; final result = await showDialog( @@ -140,6 +174,9 @@ class _WalletQrScannerDialogState extends State<_WalletQrScannerDialog> { ); if (!mounted) return; if (result == null) { + if (_usesWebRtcScanner && mounted) { + setState(() => _cameraPaused = false); + } await _scannerController?.start(); return; } @@ -195,10 +232,26 @@ class _WalletQrScannerDialogState extends State<_WalletQrScannerDialog> { Expanded( child: Stack( children: [ - MobileScanner( - controller: _scannerController!, - onDetect: _onBarcodeDetected, - ), + if (_cameraPaused) + const ColoredBox(color: Colors.black) + else if (_usesWebRtcScanner) + webrtc_zxing.ReaderWidget( + codeFormat: webrtc_zxing.Format.qrCode, + cropPercent: 0.7, + scanDelay: const Duration(milliseconds: 250), + scanDelaySuccess: const Duration(milliseconds: 250), + showGallery: false, + showToggleCamera: false, + showScannerOverlay: false, + onScan: _onWebRtcBarcodeDetected, + onRendererCreated: _onWebRtcScannerCreated, + ) + else + MobileScanner( + controller: _scannerController!, + onDetect: _onBarcodeDetected, + errorBuilder: _mobileScannerError, + ), Center( child: Container( width: 250, @@ -301,6 +354,25 @@ class _WalletQrScannerDialogState extends State<_WalletQrScannerDialog> { ); } + Widget _mobileScannerError( + BuildContext context, + MobileScannerException error, + ) { + return ColoredBox( + color: Colors.black, + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + error.toString(), + style: const TextStyle(color: Colors.white), + textAlign: TextAlign.center, + ), + ), + ), + ); + } + Widget _buildErrorMessage() { return Positioned( top: 20, @@ -443,6 +515,7 @@ class _ManualWalletInputDialogState extends State<_ManualWalletInputDialog> { late final TextEditingController _controller; WalletInputKind? _kind; bool _isLaunchingWallet = false; + bool _isScanningQr = false; bool get _isValid => _kind != null && (!widget.nwcOnly || _kind == WalletInputKind.nwc); @@ -488,6 +561,23 @@ class _ManualWalletInputDialogState extends State<_ManualWalletInputDialog> { } } + Future _scanQrCode() async { + if (_isScanningQr) return; + setState(() => _isScanningQr = true); + try { + final value = await showDialog( + context: context, + builder: (_) => const _QrCodeScannerDialog(), + ); + if (!mounted || value == null) return; + _controller.text = value; + _controller.selection = TextSelection.collapsed(offset: value.length); + setState(() => _kind = classifyWalletInput(value)); + } finally { + if (mounted) setState(() => _isScanningQr = false); + } + } + @override Widget build(BuildContext context) { final l10n = ndk_l10n.AppLocalizations.of(context)!; @@ -519,6 +609,20 @@ class _ManualWalletInputDialogState extends State<_ManualWalletInputDialog> { hintText: widget.nwcOnly ? l10n.nwcConnectionUriHint : widget.supportedInputDescription, + suffixIcon: widget.nwcOnly + ? IconButton( + onPressed: _isScanningQr ? null : _scanQrCode, + tooltip: l10n.scanWalletQrCode, + icon: _isScanningQr + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.qr_code_scanner), + ) + : null, ), ), if (widget.nwcOnly && widget.connectWalletApp != null) ...[ @@ -591,6 +695,147 @@ class _ManualWalletInputResult { connectionStarted = true; } +class _QrCodeScannerDialog extends StatefulWidget { + const _QrCodeScannerDialog(); + + @override + State<_QrCodeScannerDialog> createState() => _QrCodeScannerDialogState(); +} + +class _QrCodeScannerDialogState extends State<_QrCodeScannerDialog> { + bool _hasScanned = false; + String? _error; + + bool get _usesMobileScanner => + kIsWeb || + defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS; + + bool get _usesWebRtcScanner => + !kIsWeb && defaultTargetPlatform == TargetPlatform.linux; + + void _complete(String? value) { + final normalized = value?.trim(); + if (_hasScanned || normalized == null || normalized.isEmpty) return; + _hasScanned = true; + Navigator.of(context).pop(normalized); + } + + void _onMobileScan(BarcodeCapture capture) { + for (final barcode in capture.barcodes) { + final value = barcode.rawValue; + if (value?.trim().isNotEmpty == true) { + _complete(value); + return; + } + } + } + + void _onWebRtcScannerCreated(Object? _, Exception? error) { + if (!mounted || error == null) return; + setState(() => _error = error.toString()); + } + + @override + Widget build(BuildContext context) { + final l10n = ndk_l10n.AppLocalizations.of(context)!; + return Dialog( + backgroundColor: Colors.black, + child: SizedBox( + width: 400, + height: 560, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Expanded( + child: Text( + l10n.scanWalletQrCode, + style: const TextStyle(color: Colors.white, fontSize: 18), + textAlign: TextAlign.center, + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, color: Colors.white), + ), + ], + ), + ), + Expanded( + child: Stack( + fit: StackFit.expand, + children: [ + if (_usesWebRtcScanner) + webrtc_zxing.ReaderWidget( + codeFormat: webrtc_zxing.Format.qrCode, + cropPercent: 0.7, + scanDelay: const Duration(milliseconds: 250), + scanDelaySuccess: const Duration(milliseconds: 250), + showGallery: false, + showToggleCamera: false, + showScannerOverlay: false, + onScan: (code) => _complete(code.text), + onRendererCreated: _onWebRtcScannerCreated, + ) + else if (_usesMobileScanner) + MobileScanner( + onDetect: _onMobileScan, + errorBuilder: (context, error) => Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + error.toString(), + style: const TextStyle(color: Colors.white), + textAlign: TextAlign.center, + ), + ), + ), + ) + else + Center( + child: Text( + l10n.cameraNotAvailable, + style: const TextStyle(color: Colors.white70), + textAlign: TextAlign.center, + ), + ), + Center( + child: Container( + width: 250, + height: 250, + decoration: BoxDecoration( + border: Border.all(color: Colors.white, width: 2), + borderRadius: BorderRadius.circular(12), + ), + ), + ), + if (_error != null) + Align( + alignment: Alignment.bottomCenter, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + color: Colors.black87, + child: Text( + _error!, + style: const TextStyle(color: Colors.white), + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + class _WalletChooserDialog extends StatefulWidget { final WalletInputScannerConfiguration configuration; @@ -1223,7 +1468,7 @@ class _AlbyChooserDialog extends StatelessWidget { _albyGoOption == null ? null : () => _connectAlbyGo(context), ), ListTile( - leading: const Icon(Icons.key), + leading: const _NwcIcon(), title: Text(l10n.manualNwcConnection), trailing: const Icon(Icons.chevron_right), onTap: () => _manualNwc(context), diff --git a/packages/sample-app/linux/flutter/generated_plugin_registrant.cc b/packages/sample-app/linux/flutter/generated_plugin_registrant.cc index eff2c9719..7ce608bb2 100644 --- a/packages/sample-app/linux/flutter/generated_plugin_registrant.cc +++ b/packages/sample-app/linux/flutter/generated_plugin_registrant.cc @@ -6,16 +6,24 @@ #include "generated_plugin_registrant.h" +#include #include +#include #include #include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_webrtc_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterWebRTCPlugin"); + flutter_web_r_t_c_plugin_register_with_registrar(flutter_webrtc_registrar); g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin"); media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar); diff --git a/packages/sample-app/linux/flutter/generated_plugins.cmake b/packages/sample-app/linux/flutter/generated_plugins.cmake index ee9112294..9d9a5e0fe 100644 --- a/packages/sample-app/linux/flutter/generated_plugins.cmake +++ b/packages/sample-app/linux/flutter/generated_plugins.cmake @@ -3,7 +3,9 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux flutter_secure_storage_linux + flutter_webrtc media_kit_libs_linux media_kit_video url_launcher_linux @@ -11,6 +13,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_webrtc_zxing jni ) diff --git a/packages/sample-app/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/sample-app/macos/Flutter/GeneratedPluginRegistrant.swift index 877f9445d..819c75600 100644 --- a/packages/sample-app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/packages/sample-app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,7 +6,9 @@ import FlutterMacOS import Foundation import file_picker +import file_selector_macos import flutter_secure_storage_darwin +import flutter_webrtc import media_kit_libs_macos_video import media_kit_video import mobile_scanner @@ -18,7 +20,9 @@ import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) + FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin")) MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) diff --git a/packages/sample-app/pubspec.lock b/packages/sample-app/pubspec.lock index d7b7f4d37..99d48086f 100644 --- a/packages/sample-app/pubspec.lock +++ b/packages/sample-app/pubspec.lock @@ -161,6 +161,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + dart_webrtc: + dependency: transitive + description: + name: dart_webrtc + sha256: "078e3c431500147e5cc52b3c6ea41ed538f30c7720cc2467d2186c9251e62716" + url: "https://pub.dev" + source: hosted + version: "1.8.2" dbus: dependency: transitive description: @@ -233,6 +241,38 @@ packages: url: "https://pub.dev" source: hosted version: "10.3.10" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab + url: "https://pub.dev" + source: hosted + version: "0.9.4+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 + url: "https://pub.dev" + source: hosted + version: "0.9.5+1" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec + url: "https://pub.dev" + source: hosted + version: "0.9.3+6" fixnum: dependency: transitive description: @@ -341,6 +381,22 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_webrtc: + dependency: transitive + description: + name: flutter_webrtc + sha256: "381e05c120caf2f1ee1accd806baad22b33802f36c74d8ea5e43a5800ce6380c" + url: "https://pub.dev" + source: hosted + version: "1.6.2+hotfix.1" + flutter_webrtc_zxing: + dependency: "direct main" + description: + name: flutter_webrtc_zxing + sha256: "58a8f9bebeadfbbe080a22b0379b7757f8fa09f9d55a7c25871464e079a7ba9a" + url: "https://pub.dev" + source: hosted + version: "0.2.1" glob: dependency: transitive description: @@ -405,6 +461,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.8.0" + image_picker: + dependency: transitive + description: + name: image_picker + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "0a55d645a670d6ae11efa948f955ae904afa472f1344fc15a7ddacc20c1e7219" + url: "https://pub.dev" + source: hosted + version: "0.8.13+23" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: ee3885b6fcd71958fbc79770dd194c63371439d536d69c47b279171a486482ae + url: "https://pub.dev" + source: hosted + version: "0.8.13+7" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" intl: dependency: "direct main" description: @@ -461,6 +581,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + logger: + dependency: transitive + description: + name: logger + sha256: "2a0dc097e7b01d942475bdd552356db2d0f768b05540bd4b2b53f1840f2239a7" + url: "https://pub.dev" + source: hosted + version: "2.8.0" logging: dependency: transitive description: @@ -557,6 +685,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" mobile_scanner: dependency: "direct main" description: @@ -1215,6 +1351,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.1" + webrtc_interface: + dependency: transitive + description: + name: webrtc_interface + sha256: c6f100eac5057d9a817a60473126f9828c796d42884d498af4f339c97b21014f + url: "https://pub.dev" + source: hosted + version: "1.5.1" win32: dependency: transitive description: @@ -1264,5 +1408,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.11.0 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/packages/sample-app/pubspec.yaml b/packages/sample-app/pubspec.yaml index 93ecf291c..e8577c708 100644 --- a/packages/sample-app/pubspec.yaml +++ b/packages/sample-app/pubspec.yaml @@ -53,6 +53,7 @@ dependencies: http: ^1.2.0 qr_flutter: ^4.1.0 mobile_scanner: ^7.2.1 + flutter_webrtc_zxing: ^0.2.1 flutter_svg: ^2.2.1 convert: ^3.1.2 crypto: ^3.0.7 diff --git a/packages/sample-app/windows/flutter/generated_plugin_registrant.cc b/packages/sample-app/windows/flutter/generated_plugin_registrant.cc index c832ceb18..e403b99a7 100644 --- a/packages/sample-app/windows/flutter/generated_plugin_registrant.cc +++ b/packages/sample-app/windows/flutter/generated_plugin_registrant.cc @@ -6,7 +6,9 @@ #include "generated_plugin_registrant.h" +#include #include +#include #include #include #include @@ -14,8 +16,12 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + FlutterWebRTCPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterWebRTCPlugin")); MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); MediaKitVideoPluginCApiRegisterWithRegistrar( diff --git a/packages/sample-app/windows/flutter/generated_plugins.cmake b/packages/sample-app/windows/flutter/generated_plugins.cmake index 41c33fd08..76798b370 100644 --- a/packages/sample-app/windows/flutter/generated_plugins.cmake +++ b/packages/sample-app/windows/flutter/generated_plugins.cmake @@ -3,7 +3,9 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows flutter_secure_storage_windows + flutter_webrtc media_kit_libs_windows_video media_kit_video protocol_handler_windows @@ -12,6 +14,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_webrtc_zxing jni ) From cc77b017dd4007dd66ff3742d9d5a62ae157cf28 Mon Sep 17 00:00:00 2001 From: fmar Date: Sat, 12 Sep 2026 14:17:04 +0200 Subject: [PATCH 20/22] lnbits wallet provider --- .../providers/lnbits/lnbits_wallet.dart | 9 +- .../lnbits/lnbits_wallet_provider.dart | 36 ++-- .../wallet/providers/nwc/nwc_wallet.dart | 6 + .../providers/nwc/nwc_wallet_provider.dart | 1 + .../usecases/wallets/wallets.dart | 78 +++++++++ .../ndk/test/entities/lnbits_wallet_test.dart | 65 ++++++++ .../ndk/test/entities/nwc_wallet_test.dart | 4 + .../test/usecases/wallets_bip321_test.dart | 24 ++- .../test/usecases/wallets_transfer_test.dart | 44 ++++- packages/ndk_flutter/lib/l10n/app_de.arb | 9 +- packages/ndk_flutter/lib/l10n/app_en.arb | 9 +- packages/ndk_flutter/lib/l10n/app_es.arb | 9 +- packages/ndk_flutter/lib/l10n/app_fi.arb | 9 +- packages/ndk_flutter/lib/l10n/app_fr.arb | 9 +- packages/ndk_flutter/lib/l10n/app_it.arb | 9 +- packages/ndk_flutter/lib/l10n/app_ja.arb | 9 +- .../lib/l10n/app_localizations.dart | 42 +++++ .../lib/l10n/app_localizations_de.dart | 22 +++ .../lib/l10n/app_localizations_en.dart | 22 +++ .../lib/l10n/app_localizations_es.dart | 22 +++ .../lib/l10n/app_localizations_fi.dart | 22 +++ .../lib/l10n/app_localizations_fr.dart | 22 +++ .../lib/l10n/app_localizations_it.dart | 22 +++ .../lib/l10n/app_localizations_ja.dart | 22 +++ .../lib/l10n/app_localizations_pl.dart | 22 +++ .../lib/l10n/app_localizations_pt.dart | 44 +++++ .../lib/l10n/app_localizations_ru.dart | 22 +++ .../lib/l10n/app_localizations_sk.dart | 22 +++ .../lib/l10n/app_localizations_zh.dart | 21 +++ packages/ndk_flutter/lib/l10n/app_pl.arb | 9 +- packages/ndk_flutter/lib/l10n/app_pt.arb | 9 +- packages/ndk_flutter/lib/l10n/app_pt_BR.arb | 9 +- packages/ndk_flutter/lib/l10n/app_ru.arb | 9 +- packages/ndk_flutter/lib/l10n/app_sk.arb | 9 +- packages/ndk_flutter/lib/l10n/app_zh.arb | 9 +- .../widgets/wallets/n_add_wallet_dialogs.dart | 89 +++++++++- .../widgets/wallets/n_nwc_wallet_icon.dart | 69 ++++++++ .../lib/widgets/wallets/n_wallet_actions.dart | 29 +++- .../lib/widgets/wallets/n_wallet_card.dart | 109 +++++++++++-- .../widgets/wallets/n_wallet_card_list.dart | 1 + .../wallets/wallet_action_dialogs.dart | 19 ++- packages/sample-app/lib/nwc_qr_scanner.dart | 154 +++++++++++++++--- packages/sample-app/lib/wallets.dart | 2 + 43 files changed, 1086 insertions(+), 97 deletions(-) create mode 100644 packages/ndk_flutter/lib/widgets/wallets/n_nwc_wallet_icon.dart diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/lnbits/lnbits_wallet.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/lnbits/lnbits_wallet.dart index 0e722c424..56ee799ed 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/providers/lnbits/lnbits_wallet.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/lnbits/lnbits_wallet.dart @@ -1,15 +1,17 @@ import '../../wallet.dart'; import '../../wallet_type.dart'; -/// Wallet backed by an LNbits instance and wallet Admin Key. +/// Wallet backed by an LNbits instance and wallet API key. class LnBitsWallet extends Wallet { static const String urlMetadataKey = 'lnbitsUrl'; static const String adminKeyMetadataKey = 'adminKey'; static const String remoteWalletIdMetadataKey = 'remoteWalletId'; + static const String readOnlyMetadataKey = 'readOnly'; final String lnbitsUrl; final String adminKey; final String? remoteWalletId; + final bool readOnly; LnBitsWallet({ required super.id, @@ -19,12 +21,14 @@ class LnBitsWallet extends Wallet { required this.lnbitsUrl, required this.adminKey, this.remoteWalletId, + this.readOnly = false, Map? metadata, }) : super( metadata: Map.unmodifiable({ ...(metadata ?? const {}), urlMetadataKey: lnbitsUrl, adminKeyMetadataKey: adminKey, + readOnlyMetadataKey: readOnly, if (remoteWalletId != null) remoteWalletIdMetadataKey: remoteWalletId, }), @@ -34,7 +38,7 @@ class LnBitsWallet extends Wallet { bool get canReceive => true; @override - bool get canSend => true; + bool get canSend => !readOnly; @override Map toMetadata() => metadata; @@ -63,6 +67,7 @@ class LnBitsWallet extends Wallet { lnbitsUrl: url, adminKey: adminKey, remoteWalletId: metadata[remoteWalletIdMetadataKey] as String?, + readOnly: metadata[readOnlyMetadataKey] as bool? ?? false, metadata: metadata, ); } diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/lnbits/lnbits_wallet_provider.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/lnbits/lnbits_wallet_provider.dart index 25ff2e93a..77f4a333b 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/providers/lnbits/lnbits_wallet_provider.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/lnbits/lnbits_wallet_provider.dart @@ -38,6 +38,8 @@ class LnBitsApiException implements Exception { /// Direct LNbits REST API wallet provider. class LnBitsWalletProvider implements WalletProvider { + static const balanceRefreshInterval = Duration(seconds: 30); + final http.Client _client; LnBitsWalletProvider([http.Client? client]) @@ -100,6 +102,7 @@ class LnBitsWalletProvider implements WalletProvider { ), remoteWalletId: metadata[LnBitsWallet.remoteWalletIdMetadataKey]?.toString(), + readOnly: metadata[LnBitsWallet.readOnlyMetadataKey] as bool? ?? false, metadata: metadata, ); } @@ -119,6 +122,7 @@ class LnBitsWalletProvider implements WalletProvider { lnbitsUrl: lnbitsWallet.lnbitsUrl, adminKey: lnbitsWallet.adminKey, remoteWalletId: info.id, + readOnly: lnbitsWallet.readOnly, metadata: lnbitsWallet.metadata, ); } @@ -127,22 +131,22 @@ class LnBitsWalletProvider implements WalletProvider { Future removeWallet(Wallet wallet) async {} @override - Stream> getBalances(Wallet wallet) { + Stream> getBalances(Wallet wallet) async* { final lnbitsWallet = _asLnBitsWallet(wallet); - return Stream.fromFuture( - _getWalletInfo( + while (true) { + final info = await _getWalletInfo( lnbitsUrl: lnbitsWallet.lnbitsUrl, adminKey: lnbitsWallet.adminKey, - ).then( - (info) => [ - WalletBalance( - walletId: wallet.id, - unit: 'sat', - amount: info.balanceMsat ~/ 1000, - ), - ], - ), - ); + ); + yield [ + WalletBalance( + walletId: wallet.id, + unit: 'sat', + amount: info.balanceMsat ~/ 1000, + ), + ]; + await Future.delayed(balanceRefreshInterval); + } } @override @@ -171,6 +175,9 @@ class LnBitsWalletProvider implements WalletProvider { String invoice, { Duration? timeout, }) async { + if (_asLnBitsWallet(wallet).readOnly) { + throw UnsupportedError('LNbits invoice/read key cannot send payments'); + } final result = await _pay( _asLnBitsWallet(wallet), invoice, @@ -210,6 +217,9 @@ class LnBitsWalletProvider implements WalletProvider { Map? metadata, Duration? timeout, }) async { + if (_asLnBitsWallet(wallet).readOnly) { + throw UnsupportedError('LNbits invoice/read key cannot send payments'); + } if (payerNote?.isNotEmpty == true) { throw UnsupportedError( 'LNbits BOLT11 payments do not support payer notes'); diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart index 05f37e16f..231fc186a 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart @@ -10,9 +10,11 @@ import 'package:rxdart/rxdart.dart'; /// Manages connection to a remote wallet via NWC protocol class NwcWallet extends Wallet { static const String kPermissionsMetadataKey = 'permissions'; + static const String kProviderIdMetadataKey = 'providerId'; final String nwcUrl; final Set cachedPermissions; + final String? providerId; NwcConnection? connection; /// Remaining NWC budget in sats, cached after the last `get_budget` call. @@ -31,6 +33,7 @@ class NwcWallet extends Wallet { super.type = WalletType.NWC, required super.supportedUnits, required this.nwcUrl, + this.providerId, Set cachedPermissions = const {}, Map? metadata, }) : cachedPermissions = Set.unmodifiable(cachedPermissions), @@ -38,6 +41,7 @@ class NwcWallet extends Wallet { metadata: Map.unmodifiable({ ...(metadata ?? const {}), 'nwcUrl': nwcUrl, + if (providerId != null) kProviderIdMetadataKey: providerId, kPermissionsMetadataKey: cachedPermissions.toList(), }), ); @@ -63,6 +67,7 @@ class NwcWallet extends Wallet { name: name, supportedUnits: supportedUnits, nwcUrl: nwcUrl, + providerId: metadata[kProviderIdMetadataKey] as String?, cachedPermissions: _parsePermissions(metadata[kPermissionsMetadataKey]), metadata: metadata, ); @@ -74,6 +79,7 @@ class NwcWallet extends Wallet { name: name, supportedUnits: supportedUnits, nwcUrl: nwcUrl, + providerId: providerId, cachedPermissions: permissions, metadata: metadata, ); diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet_provider.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet_provider.dart index f9ca23483..f22618ce7 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet_provider.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet_provider.dart @@ -51,6 +51,7 @@ class NwcWalletProvider implements WalletProvider { name: name, supportedUnits: supportedUnits, nwcUrl: nwcUrl, + providerId: metadata[NwcWallet.kProviderIdMetadataKey] as String?, metadata: metadata, ); } diff --git a/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart b/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart index 39c66fac3..dff47df3b 100644 --- a/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart +++ b/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart @@ -480,6 +480,22 @@ class Wallets { return _walletBalanceStreams[walletId]!.stream; } + /// Fetches current balances from the wallet provider immediately. + Future> refreshBalance(String walletId) async { + await _initializationFuture; + final wallet = await _getWalletForOperation(walletId); + final provider = _providers[wallet.type]; + if (provider == null) { + throw StateError('No provider registered for wallet type ${wallet.type}'); + } + + final balances = await provider.getBalances(wallet).first; + _walletsBalances[walletId] = balances; + _walletBalanceStreams[walletId]?.add(balances); + _updateCombinedStreams(); + return balances; + } + Stream> getRecentTransactionsStream(String walletId) { _initRecentTransactionStream(walletId); return _walletRecentTransactionStreams[walletId]!.stream; @@ -745,6 +761,7 @@ class Wallets { final selectedProtocol = payResponse.instructionType == 'bolt12' ? WalletPaymentProtocol.bolt12 : WalletPaymentProtocol.bolt11; + await _refreshTransferredWalletData(source.id, destination.id); return WalletTransferResult( sourceWalletId: source.id, destinationWalletId: destination.id, @@ -766,6 +783,7 @@ class Wallets { payInvoiceResponse.errorMessage ?? 'Wallet transfer failed', ); } + await _refreshTransferredWalletData(source.id, destination.id); return WalletTransferResult( sourceWalletId: source.id, destinationWalletId: destination.id, @@ -776,6 +794,66 @@ class Wallets { ); } + Future _refreshTransferredWalletData( + String sourceWalletId, + String destinationWalletId, + ) async { + await Future.wait( + {sourceWalletId, destinationWalletId}.map((walletId) async { + await Future.wait([ + _refreshBalanceAfterTransfer(walletId), + _refreshActiveTransactionStreamsAfterTransfer(walletId), + ]); + }), + ); + } + + Future _refreshBalanceAfterTransfer(String walletId) async { + try { + await refreshBalance(walletId); + } catch (_) { + // Transfer succeeded. Existing balance streams can retry later. + } + } + + Future _refreshActiveTransactionStreamsAfterTransfer( + String walletId, + ) async { + final recentStream = _walletRecentTransactionStreams[walletId]; + final pendingStream = _walletPendingTransactionStreams[walletId]; + final recentIsActive = recentStream?.hasListener ?? false; + final pendingIsActive = pendingStream?.hasListener ?? false; + if (!recentIsActive && !pendingIsActive) return; + + try { + final wallet = await _getWalletForOperation(walletId); + final provider = _providers[wallet.type]; + if (provider == null) return; + + await Future.wait([ + if (recentIsActive) + provider.getRecentTransactions(wallet).first.then((transactions) { + final completed = transactions + .where((transaction) => transaction.state.isDone) + .toList(); + _walletsRecentTransactions[walletId] = completed; + recentStream!.add(completed); + }), + if (pendingIsActive) + provider.getPendingTransactions(wallet).first.then((transactions) { + final pending = transactions + .where((transaction) => transaction.state.isPending) + .toList(); + _walletsPendingTransactions[walletId] = pending; + pendingStream!.add(pending); + }), + ]); + _updateCombinedStreams(); + } catch (_) { + // Transfer succeeded. Existing transaction streams can retry later. + } + } + Future _getWalletForOperation(String walletId) async { final inMemory = _wallets.firstWhereOrNull( (wallet) => wallet.id == walletId, diff --git a/packages/ndk/test/entities/lnbits_wallet_test.dart b/packages/ndk/test/entities/lnbits_wallet_test.dart index 14b3af5fc..732c6af12 100644 --- a/packages/ndk/test/entities/lnbits_wallet_test.dart +++ b/packages/ndk/test/entities/lnbits_wallet_test.dart @@ -59,6 +59,28 @@ void main() { isA()); }); + test('invoice/read key creates receive-only wallet and round-trips', () { + final readOnlyWallet = LnBitsWallet( + id: 'read-only', + name: 'Read-only LNbits', + supportedUnits: const {'sat'}, + lnbitsUrl: url, + adminKey: 'invoice-key', + readOnly: true, + ); + + expect(readOnlyWallet.canSend, isFalse); + expect(readOnlyWallet.canReceive, isTrue); + final restored = LnBitsWallet.fromStorage( + id: readOnlyWallet.id, + name: readOnlyWallet.name, + supportedUnits: readOnlyWallet.supportedUnits, + metadata: readOnlyWallet.toMetadata(), + ); + expect(restored.readOnly, isTrue); + expect(restored.adminKey, 'invoice-key'); + }); + test('rejects invalid URLs and missing credentials', () { expect( () => LnBitsWalletProvider.normalizeUrl('lnbits.example'), @@ -162,6 +184,29 @@ void main() { isTrue); }); + test('invoice/read key rejects outgoing payments before HTTP request', + () async { + var requested = false; + final provider = LnBitsWalletProvider(MockClient((_) async { + requested = true; + return http.Response('{}', 200); + })); + final readOnlyWallet = LnBitsWallet( + id: 'read-only', + name: 'Read-only LNbits', + supportedUnits: const {'sat'}, + lnbitsUrl: url, + adminKey: 'invoice-key', + readOnly: true, + ); + + await expectLater( + provider.send(readOnlyWallet, 'lnbc1invoice'), + throwsUnsupportedError, + ); + expect(requested, isFalse); + }); + test('maps pending and completed LNbits transactions', () async { final provider = LnBitsWalletProvider(MockClient((_) async { return http.Response( @@ -213,5 +258,25 @@ void main() { ), ); }); + + test('rejects outgoing payment when configured with invoice/read key', + () async { + final provider = LnBitsWalletProvider(MockClient((_) async { + throw StateError('HTTP must not be called'); + })); + final readOnlyWallet = LnBitsWallet( + id: 'read-only', + name: 'Read-only LNbits', + supportedUnits: const {'sat'}, + lnbitsUrl: url, + adminKey: 'invoice-key', + readOnly: true, + ); + + await expectLater( + provider.send(readOnlyWallet, 'lnbc1invoice'), + throwsUnsupportedError, + ); + }); }); } diff --git a/packages/ndk/test/entities/nwc_wallet_test.dart b/packages/ndk/test/entities/nwc_wallet_test.dart index 85dd3f140..44935ca3f 100644 --- a/packages/ndk/test/entities/nwc_wallet_test.dart +++ b/packages/ndk/test/entities/nwc_wallet_test.dart @@ -17,11 +17,13 @@ void main() { NwcMethod.MAKE_INVOICE.name, NwcMethod.PAY_INVOICE.name, ], + NwcWallet.kProviderIdMetadataKey: 'alby', }, ); expect(wallet.canReceive, isTrue); expect(wallet.canSend, isTrue); + expect(wallet.providerId, 'alby'); expect( wallet.cachedPermissions, containsAll([NwcMethod.MAKE_INVOICE.name, NwcMethod.PAY_INVOICE.name]), @@ -35,6 +37,7 @@ void main() { supportedUnits: {'sat'}, nwcUrl: 'nostr+walletconnect://a?relay=wss://relay.example&secret=secret', + providerId: 'coinos', ); final updated = wallet.withCachedPermissions({ @@ -43,6 +46,7 @@ void main() { expect(updated.canSend, isTrue); expect(updated.canReceive, isFalse); + expect(updated.providerId, 'coinos'); expect(updated.metadata[NwcWallet.kPermissionsMetadataKey], [ NwcMethod.PAY_INVOICE.name, ]); diff --git a/packages/ndk/test/usecases/wallets_bip321_test.dart b/packages/ndk/test/usecases/wallets_bip321_test.dart index c8608bd03..9b1488cfd 100644 --- a/packages/ndk/test/usecases/wallets_bip321_test.dart +++ b/packages/ndk/test/usecases/wallets_bip321_test.dart @@ -53,6 +53,20 @@ void main() { expect(provider.receiveMetadata, {'order_id': '456'}); expect(provider.receiveTimeout, const Duration(seconds: 15)); }); + + test('Wallets refreshes balance from provider on demand', () async { + final wallet = _TestWallet(); + final repository = MemWalletsRepo(); + await repository.storeWallet(wallet); + final provider = _TestWalletProvider(wallet); + final wallets = Wallets(providers: [provider], repository: repository); + addTearDown(wallets.dispose); + + final balances = await wallets.refreshBalance(wallet.id); + + expect(balances.single.amount, 42); + expect(provider.balanceRequests, 1); + }); } class _TestWallet extends Wallet { @@ -80,6 +94,8 @@ class _TestWalletProvider extends WalletProvider { _TestWalletProvider(this.wallet); + int balanceRequests = 0; + final payResponse = PayResponse( resultType: 'pay', transactionId: 'pay-transaction', @@ -125,8 +141,12 @@ class _TestWalletProvider extends WalletProvider { Stream> get discoveredWallets => Stream.value(const []); @override - Stream> getBalances(Wallet wallet) => - Stream.value(const []); + Stream> getBalances(Wallet wallet) { + balanceRequests++; + return Stream.value([ + WalletBalance(walletId: wallet.id, unit: 'sat', amount: 42), + ]); + } @override Stream> getPendingTransactions(Wallet wallet) => diff --git a/packages/ndk/test/usecases/wallets_transfer_test.dart b/packages/ndk/test/usecases/wallets_transfer_test.dart index 03cf13222..5e6bc394e 100644 --- a/packages/ndk/test/usecases/wallets_transfer_test.dart +++ b/packages/ndk/test/usecases/wallets_transfer_test.dart @@ -30,6 +30,14 @@ void main() { [sourceProvider, destinationProvider], ); addTearDown(wallets.dispose); + final recentStream = wallets.getRecentTransactionsStream(source.id); + final pendingStream = wallets.getPendingTransactionsStream(destination.id); + final recentSubscription = recentStream.listen((_) {}); + final pendingSubscription = pendingStream.listen((_) {}); + addTearDown(recentSubscription.cancel); + addTearDown(pendingSubscription.cancel); + await recentStream.first; + await pendingStream.first; expect( wallets.compatibleTransferProtocol( @@ -51,6 +59,12 @@ void main() { expect(sourceProvider.paidPayment, 'bitcoin:?lno=lno1offer'); expect(sourceProvider.paidAmountMsat, 21000); expect(sourceProvider.paidMetadata, isNull); + expect(sourceProvider.balanceRequests, 1); + expect(destinationProvider.balanceRequests, 1); + expect(sourceProvider.recentTransactionRequests, 2); + expect(sourceProvider.pendingTransactionRequests, 0); + expect(destinationProvider.recentTransactionRequests, 0); + expect(destinationProvider.pendingTransactionRequests, 2); }); test('transfers between legacy wallets with a fresh BOLT11 invoice', @@ -67,6 +81,7 @@ void main() { ); final sourceProvider = _TestWalletProvider(source.type); final destinationProvider = _TestWalletProvider(destination.type) + ..failBalanceRefresh = true ..invoiceToReceive = 'lnbc1internaltransfer'; final wallets = await _wallets( [source, destination], @@ -83,6 +98,8 @@ void main() { expect(result.protocol, WalletPaymentProtocol.bolt11); expect(destinationProvider.receivedAmountSats, 42); expect(sourceProvider.paidInvoice, 'lnbc1internaltransfer'); + expect(sourceProvider.balanceRequests, 1); + expect(destinationProvider.balanceRequests, 1); expect( result.payment, 'bitcoin:?lightning=lnbc1internaltransfer', @@ -198,6 +215,10 @@ class _TestWalletProvider implements WalletProvider { int? paidAmountMsat; Map? paidMetadata; Map? receivedMetadata; + int balanceRequests = 0; + int recentTransactionRequests = 0; + int pendingTransactionRequests = 0; + bool failBalanceRefresh = false; _TestWalletProvider(this.type); @@ -214,16 +235,27 @@ class _TestWalletProvider implements WalletProvider { Stream> get discoveredWallets => Stream.value(const []); @override - Stream> getBalances(Wallet wallet) => - Stream.value(const []); + Stream> getBalances(Wallet wallet) { + balanceRequests++; + if (failBalanceRefresh) { + return Stream.error(StateError('balance unavailable')); + } + return Stream.value([ + WalletBalance(walletId: wallet.id, unit: 'sat', amount: 1), + ]); + } @override - Stream> getPendingTransactions(Wallet wallet) => - Stream.value(const []); + Stream> getPendingTransactions(Wallet wallet) { + pendingTransactionRequests++; + return Stream.value(const []); + } @override - Stream> getRecentTransactions(Wallet wallet) => - Stream.value(const []); + Stream> getRecentTransactions(Wallet wallet) { + recentTransactionRequests++; + return Stream.value(const []); + } @override Future initialize(Wallet wallet) async => null; diff --git a/packages/ndk_flutter/lib/l10n/app_de.arb b/packages/ndk_flutter/lib/l10n/app_de.arb index 5f6be1940..02cd201e0 100644 --- a/packages/ndk_flutter/lib/l10n/app_de.arb +++ b/packages/ndk_flutter/lib/l10n/app_de.arb @@ -2,6 +2,9 @@ "lnbitsWalletOption": "LNbits", "lnbitsConnectionInstructions": "Wähle in LNbits die Wallet aus, die du verbinden möchtest, öffne sie, klicke auf API-Dokumentation und kopiere den Admin Key. Füge ihn unten ein:", "lnbitsAdminKey": "LNbits Admin Key", + "lnbitsKeyType": "LNbits-Schlüsseltyp", + "lnbitsInvoiceReadKey": "LNbits Rechnungs-/Leseschlüssel", + "lnbitsReadOnlyDescription": "Nur-Empfangs-Wallet: Guthaben und Verlauf anzeigen und Rechnungen erstellen. Zahlungen sind deaktiviert.", "lnbitsUrl": "LNbits-URL", "lnbitsCredentialsRequired": "Gib den LNbits Admin Key und die URL ein.", "lnbitsWalletAdded": "LNbits-Wallet erfolgreich hinzugefügt", @@ -473,6 +476,8 @@ "scanNwcInstructions": "Scannen Sie den QR-Code aus Ihrer NWC-Wallet-App", "invalidNwcUri": "Ungültige NWC-URI", "paste": "Einfügen", + "clearInput": "Eingabe löschen", + "pasteOrEnter": "Einfügen oder eingeben", "fromYourProfile": "Aus deinem Profil", "orEnterManually": "Oder manuell eingeben:", "budgetUsedOf": "Budget: {used} / {total}", @@ -512,5 +517,7 @@ "walletName": "Wallet-Name", "walletNameHint": "Wallet-Namen eingeben", "save": "Speichern", - "walletRenamed": "Wallet umbenannt" + "walletRenamed": "Wallet umbenannt", + "refreshBalance": "Guthaben aktualisieren", + "balanceRefreshed": "Guthaben aktualisiert" } diff --git a/packages/ndk_flutter/lib/l10n/app_en.arb b/packages/ndk_flutter/lib/l10n/app_en.arb index f094ed9ba..6c009b01e 100644 --- a/packages/ndk_flutter/lib/l10n/app_en.arb +++ b/packages/ndk_flutter/lib/l10n/app_en.arb @@ -2,6 +2,9 @@ "lnbitsWalletOption": "LNbits", "lnbitsConnectionInstructions": "In LNbits, choose the wallet you want to connect, open it, click API docs, and copy the Admin Key. Paste it below:", "lnbitsAdminKey": "LNbits Admin Key", + "lnbitsKeyType": "LNbits key type", + "lnbitsInvoiceReadKey": "LNbits invoice/read key", + "lnbitsReadOnlyDescription": "Receive-only wallet: view balance and history, and create invoices. Sending payments is disabled.", "lnbitsUrl": "LNbits URL", "lnbitsCredentialsRequired": "Enter both the LNbits Admin Key and URL.", "lnbitsWalletAdded": "LNbits wallet added successfully", @@ -1464,6 +1467,8 @@ "description": "Error message for invalid NWC URI" }, "paste": "Paste", + "clearInput": "Clear input", + "pasteOrEnter": "Paste or type", "@paste": { "description": "Label for paste action" }, @@ -1689,5 +1694,7 @@ "walletDetailTerms": "Terms of service", "walletDetailMessage": "Message", "walletDetailCommunityRating": "Community rating", - "walletDetailCommunityReviews": "Recent community reviews" + "walletDetailCommunityReviews": "Recent community reviews", + "refreshBalance": "Refresh balance", + "balanceRefreshed": "Balance refreshed" } diff --git a/packages/ndk_flutter/lib/l10n/app_es.arb b/packages/ndk_flutter/lib/l10n/app_es.arb index ada3c9fe4..d2e4e5398 100644 --- a/packages/ndk_flutter/lib/l10n/app_es.arb +++ b/packages/ndk_flutter/lib/l10n/app_es.arb @@ -2,6 +2,9 @@ "lnbitsWalletOption": "LNbits", "lnbitsConnectionInstructions": "En LNbits, elige la cartera que quieres conectar, ábrela, pulsa Documentación de API y copia la clave de administrador. Pégala abajo:", "lnbitsAdminKey": "Clave de administrador de LNbits", + "lnbitsKeyType": "Tipo de clave de LNbits", + "lnbitsInvoiceReadKey": "Clave de facturación/lectura de LNbits", + "lnbitsReadOnlyDescription": "Cartera solo para recibir: permite ver el saldo y el historial y crear facturas. Los pagos están desactivados.", "lnbitsUrl": "URL de LNbits", "lnbitsCredentialsRequired": "Introduce la clave de administrador y la URL de LNbits.", "lnbitsWalletAdded": "Cartera LNbits añadida correctamente", @@ -415,6 +418,8 @@ "scanNwcInstructions": "Escanee el código QR de su aplicación de billetera NWC", "invalidNwcUri": "URI NWC inválida", "paste": "Pegar", + "clearInput": "Borrar entrada", + "pasteOrEnter": "Pegar o escribir", "fromYourProfile": "De tu perfil", "orEnterManually": "O ingresa manualmente:", "budgetUsedOf": "Presupuesto: {used} / {total}", @@ -454,5 +459,7 @@ "walletName": "Nombre de la cartera", "walletNameHint": "Ingrese el nombre de la cartera", "save": "Guardar", - "walletRenamed": "Cartera renombrada" + "walletRenamed": "Cartera renombrada", + "refreshBalance": "Actualizar saldo", + "balanceRefreshed": "Saldo actualizado" } diff --git a/packages/ndk_flutter/lib/l10n/app_fi.arb b/packages/ndk_flutter/lib/l10n/app_fi.arb index 1b372d0ec..b36498801 100644 --- a/packages/ndk_flutter/lib/l10n/app_fi.arb +++ b/packages/ndk_flutter/lib/l10n/app_fi.arb @@ -2,6 +2,9 @@ "lnbitsWalletOption": "LNbits", "lnbitsConnectionInstructions": "Valitse LNbitsissä yhdistettävä lompakko, avaa se, napsauta API-ohjeita ja kopioi ylläpitäjän avain. Liitä se alle:", "lnbitsAdminKey": "LNbits-ylläpitäjän avain", + "lnbitsKeyType": "LNbits-avaimen tyyppi", + "lnbitsInvoiceReadKey": "LNbits-laskutus-/lukuavain", + "lnbitsReadOnlyDescription": "Vain vastaanottava lompakko: näytä saldo ja historia sekä luo laskuja. Maksujen lähetys on poistettu käytöstä.", "lnbitsUrl": "LNbits-URL", "lnbitsCredentialsRequired": "Anna LNbits-ylläpitäjän avain ja URL-osoite.", "lnbitsWalletAdded": "LNbits-lompakko lisättiin", @@ -473,6 +476,8 @@ "scanNwcInstructions": "Skannaa QR-koodi NWC-lompakkosovelluksestasi", "invalidNwcUri": "Virheellinen NWC URI", "paste": "Liitä", + "clearInput": "Tyhjennä syöte", + "pasteOrEnter": "Liitä tai kirjoita", "fromYourProfile": "Profiilistasi", "orEnterManually": "Tai syötä manuaalisesti:", "renameWallet": "Nimeä uudelleen", @@ -512,5 +517,7 @@ "budgetWeekly": "Viikoittain", "budgetMonthly": "Kuukausittain", "budgetYearly": "Vuosittain", - "budgetNever": "Ei koskaan" + "budgetNever": "Ei koskaan", + "refreshBalance": "Päivitä saldo", + "balanceRefreshed": "Saldo päivitetty" } diff --git a/packages/ndk_flutter/lib/l10n/app_fr.arb b/packages/ndk_flutter/lib/l10n/app_fr.arb index bd87d2f06..b7867ae3c 100644 --- a/packages/ndk_flutter/lib/l10n/app_fr.arb +++ b/packages/ndk_flutter/lib/l10n/app_fr.arb @@ -2,6 +2,9 @@ "lnbitsWalletOption": "LNbits", "lnbitsConnectionInstructions": "Dans LNbits, choisissez le portefeuille à connecter, ouvrez-le, cliquez sur Documentation API et copiez la clé administrateur. Collez-la ci-dessous :", "lnbitsAdminKey": "Clé administrateur LNbits", + "lnbitsKeyType": "Type de clé LNbits", + "lnbitsInvoiceReadKey": "Clé de facturation/lecture LNbits", + "lnbitsReadOnlyDescription": "Portefeuille de réception uniquement : consultez le solde et l’historique et créez des factures. L’envoi est désactivé.", "lnbitsUrl": "URL LNbits", "lnbitsCredentialsRequired": "Saisissez la clé administrateur et l’URL LNbits.", "lnbitsWalletAdded": "Portefeuille LNbits ajouté", @@ -415,6 +418,8 @@ "scanNwcInstructions": "Scannez le code QR de votre application de portefeuille NWC", "invalidNwcUri": "URI NWC invalide", "paste": "Coller", + "clearInput": "Effacer la saisie", + "pasteOrEnter": "Coller ou saisir", "fromYourProfile": "De votre profil", "orEnterManually": "Ou saisissez manuellement:", "budgetUsedOf": "Budget : {used} / {total}", @@ -454,5 +459,7 @@ "walletName": "Nom du portefeuille", "walletNameHint": "Saisir le nom du portefeuille", "save": "Enregistrer", - "walletRenamed": "Portefeuille renommé" + "walletRenamed": "Portefeuille renommé", + "refreshBalance": "Actualiser le solde", + "balanceRefreshed": "Solde actualisé" } diff --git a/packages/ndk_flutter/lib/l10n/app_it.arb b/packages/ndk_flutter/lib/l10n/app_it.arb index 60d82580a..ac74eaa8f 100644 --- a/packages/ndk_flutter/lib/l10n/app_it.arb +++ b/packages/ndk_flutter/lib/l10n/app_it.arb @@ -2,6 +2,9 @@ "lnbitsWalletOption": "LNbits", "lnbitsConnectionInstructions": "In LNbits, scegli il portafoglio da collegare, aprilo, fai clic su Documentazione API e copia la chiave amministratore. Incollala qui sotto:", "lnbitsAdminKey": "Chiave amministratore LNbits", + "lnbitsKeyType": "Tipo di chiave LNbits", + "lnbitsInvoiceReadKey": "Chiave fatture/lettura LNbits", + "lnbitsReadOnlyDescription": "Portafoglio di sola ricezione: visualizza saldo e cronologia e crea fatture. L’invio di pagamenti è disabilitato.", "lnbitsUrl": "URL LNbits", "lnbitsCredentialsRequired": "Inserisci la chiave amministratore e l’URL LNbits.", "lnbitsWalletAdded": "Portafoglio LNbits aggiunto", @@ -473,6 +476,8 @@ "scanNwcInstructions": "Scansiona il codice QR dalla tua app di portafoglio NWC", "invalidNwcUri": "URI NWC non valido", "paste": "Incolla", + "clearInput": "Cancella testo", + "pasteOrEnter": "Incolla o digita", "fromYourProfile": "Dal tuo profilo", "orEnterManually": "Oppure inserisci manualmente:", "budgetUsedOf": "Bilancio: {used} / {total}", @@ -512,5 +517,7 @@ "walletName": "Nome del portafoglio", "walletNameHint": "Inserisci il nome del portafoglio", "save": "Salva", - "walletRenamed": "Portafoglio rinominato" + "walletRenamed": "Portafoglio rinominato", + "refreshBalance": "Aggiorna saldo", + "balanceRefreshed": "Saldo aggiornato" } diff --git a/packages/ndk_flutter/lib/l10n/app_ja.arb b/packages/ndk_flutter/lib/l10n/app_ja.arb index 9b092e203..da3acde59 100644 --- a/packages/ndk_flutter/lib/l10n/app_ja.arb +++ b/packages/ndk_flutter/lib/l10n/app_ja.arb @@ -2,6 +2,9 @@ "lnbitsWalletOption": "LNbits", "lnbitsConnectionInstructions": "LNbitsで接続するウォレットを選んで開き、APIドキュメントを押して管理者キーをコピーしてください。下に貼り付けます:", "lnbitsAdminKey": "LNbits管理者キー", + "lnbitsKeyType": "LNbitsキーの種類", + "lnbitsInvoiceReadKey": "LNbits請求書・読み取りキー", + "lnbitsReadOnlyDescription": "受信専用ウォレット:残高と履歴の表示、請求書の作成ができます。支払いの送信は無効です。", "lnbitsUrl": "LNbits URL", "lnbitsCredentialsRequired": "LNbits管理者キーとURLを入力してください。", "lnbitsWalletAdded": "LNbitsウォレットを追加しました", @@ -415,6 +418,8 @@ "scanNwcInstructions": "NWCウォレットアプリからQRコードをスキャンしてください", "invalidNwcUri": "無効なNWC URI", "paste": "貼り付け", + "clearInput": "入力を消去", + "pasteOrEnter": "貼り付けまたは入力", "fromYourProfile": "プロフィールから", "orEnterManually": "または手動で入力:", "budgetUsedOf": "予算: {used} / {total}", @@ -454,5 +459,7 @@ "walletName": "ウォレット名", "walletNameHint": "ウォレット名を入力", "save": "保存", - "walletRenamed": "ウォレット名を変更しました" + "walletRenamed": "ウォレット名を変更しました", + "refreshBalance": "残高を更新", + "balanceRefreshed": "残高を更新しました" } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations.dart b/packages/ndk_flutter/lib/l10n/app_localizations.dart index d32cf7192..f1a7e0510 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations.dart @@ -137,6 +137,24 @@ abstract class AppLocalizations { /// **'LNbits Admin Key'** String get lnbitsAdminKey; + /// No description provided for @lnbitsKeyType. + /// + /// In en, this message translates to: + /// **'LNbits key type'** + String get lnbitsKeyType; + + /// No description provided for @lnbitsInvoiceReadKey. + /// + /// In en, this message translates to: + /// **'LNbits invoice/read key'** + String get lnbitsInvoiceReadKey; + + /// No description provided for @lnbitsReadOnlyDescription. + /// + /// In en, this message translates to: + /// **'Receive-only wallet: view balance and history, and create invoices. Sending payments is disabled.'** + String get lnbitsReadOnlyDescription; + /// No description provided for @lnbitsUrl. /// /// In en, this message translates to: @@ -2405,6 +2423,18 @@ abstract class AppLocalizations { /// **'Paste'** String get paste; + /// No description provided for @clearInput. + /// + /// In en, this message translates to: + /// **'Clear input'** + String get clearInput; + + /// No description provided for @pasteOrEnter. + /// + /// In en, this message translates to: + /// **'Paste or type'** + String get pasteOrEnter; + /// Label indicating a value comes from user's profile /// /// In en, this message translates to: @@ -2890,6 +2920,18 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Recent community reviews'** String get walletDetailCommunityReviews; + + /// No description provided for @refreshBalance. + /// + /// In en, this message translates to: + /// **'Refresh balance'** + String get refreshBalance; + + /// No description provided for @balanceRefreshed. + /// + /// In en, this message translates to: + /// **'Balance refreshed'** + String get balanceRefreshed; } 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 213f221e2..419a3a56c 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_de.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_de.dart @@ -18,6 +18,16 @@ class AppLocalizationsDe extends AppLocalizations { @override String get lnbitsAdminKey => 'LNbits Admin Key'; + @override + String get lnbitsKeyType => 'LNbits-Schlüsseltyp'; + + @override + String get lnbitsInvoiceReadKey => 'LNbits Rechnungs-/Leseschlüssel'; + + @override + String get lnbitsReadOnlyDescription => + 'Nur-Empfangs-Wallet: Guthaben und Verlauf anzeigen und Rechnungen erstellen. Zahlungen sind deaktiviert.'; + @override String get lnbitsUrl => 'LNbits-URL'; @@ -1202,6 +1212,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get paste => 'Einfügen'; + @override + String get clearInput => 'Eingabe löschen'; + + @override + String get pasteOrEnter => 'Einfügen oder eingeben'; + @override String get fromYourProfile => 'Aus deinem Profil'; @@ -1474,4 +1490,10 @@ class AppLocalizationsDe extends AppLocalizations { @override String get walletDetailCommunityReviews => 'Aktuelle Community-Rezensionen'; + + @override + String get refreshBalance => 'Guthaben aktualisieren'; + + @override + String get balanceRefreshed => 'Guthaben aktualisiert'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_en.dart b/packages/ndk_flutter/lib/l10n/app_localizations_en.dart index ff3e5261a..eedec5a25 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_en.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_en.dart @@ -18,6 +18,16 @@ class AppLocalizationsEn extends AppLocalizations { @override String get lnbitsAdminKey => 'LNbits Admin Key'; + @override + String get lnbitsKeyType => 'LNbits key type'; + + @override + String get lnbitsInvoiceReadKey => 'LNbits invoice/read key'; + + @override + String get lnbitsReadOnlyDescription => + 'Receive-only wallet: view balance and history, and create invoices. Sending payments is disabled.'; + @override String get lnbitsUrl => 'LNbits URL'; @@ -1194,6 +1204,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get paste => 'Paste'; + @override + String get clearInput => 'Clear input'; + + @override + String get pasteOrEnter => 'Paste or type'; + @override String get fromYourProfile => 'From your profile'; @@ -1465,4 +1481,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get walletDetailCommunityReviews => 'Recent community reviews'; + + @override + String get refreshBalance => 'Refresh balance'; + + @override + String get balanceRefreshed => 'Balance refreshed'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_es.dart b/packages/ndk_flutter/lib/l10n/app_localizations_es.dart index 228aff721..fe0b76857 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_es.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_es.dart @@ -18,6 +18,16 @@ class AppLocalizationsEs extends AppLocalizations { @override String get lnbitsAdminKey => 'Clave de administrador de LNbits'; + @override + String get lnbitsKeyType => 'Tipo de clave de LNbits'; + + @override + String get lnbitsInvoiceReadKey => 'Clave de facturación/lectura de LNbits'; + + @override + String get lnbitsReadOnlyDescription => + 'Cartera solo para recibir: permite ver el saldo y el historial y crear facturas. Los pagos están desactivados.'; + @override String get lnbitsUrl => 'URL de LNbits'; @@ -1203,6 +1213,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String get paste => 'Pegar'; + @override + String get clearInput => 'Borrar entrada'; + + @override + String get pasteOrEnter => 'Pegar o escribir'; + @override String get fromYourProfile => 'De tu perfil'; @@ -1475,4 +1491,10 @@ class AppLocalizationsEs extends AppLocalizations { @override String get walletDetailCommunityReviews => 'Reseñas recientes de la comunidad'; + + @override + String get refreshBalance => 'Actualizar saldo'; + + @override + String get balanceRefreshed => 'Saldo actualizado'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart b/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart index a4f6ed856..9e6598496 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart @@ -18,6 +18,16 @@ class AppLocalizationsFi extends AppLocalizations { @override String get lnbitsAdminKey => 'LNbits-ylläpitäjän avain'; + @override + String get lnbitsKeyType => 'LNbits-avaimen tyyppi'; + + @override + String get lnbitsInvoiceReadKey => 'LNbits-laskutus-/lukuavain'; + + @override + String get lnbitsReadOnlyDescription => + 'Vain vastaanottava lompakko: näytä saldo ja historia sekä luo laskuja. Maksujen lähetys on poistettu käytöstä.'; + @override String get lnbitsUrl => 'LNbits-URL'; @@ -1198,6 +1208,12 @@ class AppLocalizationsFi extends AppLocalizations { @override String get paste => 'Liitä'; + @override + String get clearInput => 'Tyhjennä syöte'; + + @override + String get pasteOrEnter => 'Liitä tai kirjoita'; + @override String get fromYourProfile => 'Profiilistasi'; @@ -1471,4 +1487,10 @@ class AppLocalizationsFi extends AppLocalizations { @override String get walletDetailCommunityReviews => 'Viimeisimmät yhteisöarvostelut'; + + @override + String get refreshBalance => 'Päivitä saldo'; + + @override + String get balanceRefreshed => 'Saldo päivitetty'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart b/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart index fa3871a99..da908f02c 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart @@ -18,6 +18,16 @@ class AppLocalizationsFr extends AppLocalizations { @override String get lnbitsAdminKey => 'Clé administrateur LNbits'; + @override + String get lnbitsKeyType => 'Type de clé LNbits'; + + @override + String get lnbitsInvoiceReadKey => 'Clé de facturation/lecture LNbits'; + + @override + String get lnbitsReadOnlyDescription => + 'Portefeuille de réception uniquement : consultez le solde et l’historique et créez des factures. L’envoi est désactivé.'; + @override String get lnbitsUrl => 'URL LNbits'; @@ -1202,6 +1212,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String get paste => 'Coller'; + @override + String get clearInput => 'Effacer la saisie'; + + @override + String get pasteOrEnter => 'Coller ou saisir'; + @override String get fromYourProfile => 'De votre profil'; @@ -1475,4 +1491,10 @@ class AppLocalizationsFr extends AppLocalizations { @override String get walletDetailCommunityReviews => 'Avis récents de la communauté'; + + @override + String get refreshBalance => 'Actualiser le solde'; + + @override + String get balanceRefreshed => 'Solde actualisé'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_it.dart b/packages/ndk_flutter/lib/l10n/app_localizations_it.dart index 35ea5c270..f39561a8f 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_it.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_it.dart @@ -18,6 +18,16 @@ class AppLocalizationsIt extends AppLocalizations { @override String get lnbitsAdminKey => 'Chiave amministratore LNbits'; + @override + String get lnbitsKeyType => 'Tipo di chiave LNbits'; + + @override + String get lnbitsInvoiceReadKey => 'Chiave fatture/lettura LNbits'; + + @override + String get lnbitsReadOnlyDescription => + 'Portafoglio di sola ricezione: visualizza saldo e cronologia e crea fatture. L’invio di pagamenti è disabilitato.'; + @override String get lnbitsUrl => 'URL LNbits'; @@ -1204,6 +1214,12 @@ class AppLocalizationsIt extends AppLocalizations { @override String get paste => 'Incolla'; + @override + String get clearInput => 'Cancella testo'; + + @override + String get pasteOrEnter => 'Incolla o digita'; + @override String get fromYourProfile => 'Dal tuo profilo'; @@ -1476,4 +1492,10 @@ class AppLocalizationsIt extends AppLocalizations { @override String get walletDetailCommunityReviews => 'Recensioni recenti della community'; + + @override + String get refreshBalance => 'Aggiorna saldo'; + + @override + String get balanceRefreshed => 'Saldo aggiornato'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart b/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart index 20702ae41..474f2513d 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart @@ -18,6 +18,16 @@ class AppLocalizationsJa extends AppLocalizations { @override String get lnbitsAdminKey => 'LNbits管理者キー'; + @override + String get lnbitsKeyType => 'LNbitsキーの種類'; + + @override + String get lnbitsInvoiceReadKey => 'LNbits請求書・読み取りキー'; + + @override + String get lnbitsReadOnlyDescription => + '受信専用ウォレット:残高と履歴の表示、請求書の作成ができます。支払いの送信は無効です。'; + @override String get lnbitsUrl => 'LNbits URL'; @@ -1182,6 +1192,12 @@ class AppLocalizationsJa extends AppLocalizations { @override String get paste => '貼り付け'; + @override + String get clearInput => '入力を消去'; + + @override + String get pasteOrEnter => '貼り付けまたは入力'; + @override String get fromYourProfile => 'プロフィールから'; @@ -1451,4 +1467,10 @@ class AppLocalizationsJa extends AppLocalizations { @override String get walletDetailCommunityReviews => '最近のコミュニティレビュー'; + + @override + String get refreshBalance => '残高を更新'; + + @override + String get balanceRefreshed => '残高を更新しました'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart b/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart index 37490ce69..563d6065f 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart @@ -18,6 +18,16 @@ class AppLocalizationsPl extends AppLocalizations { @override String get lnbitsAdminKey => 'Klucz administratora LNbits'; + @override + String get lnbitsKeyType => 'Typ klucza LNbits'; + + @override + String get lnbitsInvoiceReadKey => 'Klucz faktur/odczytu LNbits'; + + @override + String get lnbitsReadOnlyDescription => + 'Portfel tylko do odbioru: wyświetla saldo i historię oraz tworzy faktury. Wysyłanie płatności jest wyłączone.'; + @override String get lnbitsUrl => 'Adres URL LNbits'; @@ -1202,6 +1212,12 @@ class AppLocalizationsPl extends AppLocalizations { @override String get paste => 'Wklej'; + @override + String get clearInput => 'Wyczyść pole'; + + @override + String get pasteOrEnter => 'Wklej lub wpisz'; + @override String get fromYourProfile => 'Z twojego profilu'; @@ -1474,4 +1490,10 @@ class AppLocalizationsPl extends AppLocalizations { @override String get walletDetailCommunityReviews => 'Najnowsze recenzje społeczności'; + + @override + String get refreshBalance => 'Odśwież saldo'; + + @override + String get balanceRefreshed => 'Saldo odświeżone'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart b/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart index dbb3acd3f..7a502f1fe 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart @@ -18,6 +18,16 @@ class AppLocalizationsPt extends AppLocalizations { @override String get lnbitsAdminKey => 'Chave de administrador LNbits'; + @override + String get lnbitsKeyType => 'Tipo de chave LNbits'; + + @override + String get lnbitsInvoiceReadKey => 'Chave de fatura/leitura LNbits'; + + @override + String get lnbitsReadOnlyDescription => + 'Carteira apenas para receber: consulte saldo e histórico e crie faturas. O envio de pagamentos está desativado.'; + @override String get lnbitsUrl => 'URL do LNbits'; @@ -1206,6 +1216,12 @@ class AppLocalizationsPt extends AppLocalizations { @override String get paste => 'Colar'; + @override + String get clearInput => 'Limpar entrada'; + + @override + String get pasteOrEnter => 'Colar ou digitar'; + @override String get fromYourProfile => 'Do seu perfil'; @@ -1479,6 +1495,12 @@ class AppLocalizationsPt extends AppLocalizations { @override String get walletDetailCommunityReviews => 'Avaliações recentes da comunidade'; + + @override + String get refreshBalance => 'Atualizar saldo'; + + @override + String get balanceRefreshed => 'Saldo atualizado'; } /// The translations for Portuguese, as used in Brazil (`pt_BR`). @@ -1495,6 +1517,16 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get lnbitsAdminKey => 'Chave de administrador LNbits'; + @override + String get lnbitsKeyType => 'Tipo de chave LNbits'; + + @override + String get lnbitsInvoiceReadKey => 'Chave de fatura/leitura LNbits'; + + @override + String get lnbitsReadOnlyDescription => + 'Carteira somente para receber: consulte saldo e histórico e crie faturas. O envio de pagamentos está desativado.'; + @override String get lnbitsUrl => 'URL do LNbits'; @@ -2661,6 +2693,12 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get paste => 'Colar'; + @override + String get clearInput => 'Limpar entrada'; + + @override + String get pasteOrEnter => 'Colar ou digitar'; + @override String get fromYourProfile => 'Do seu perfil'; @@ -2844,4 +2882,10 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get walletDetailCommunityReviews => 'Avaliações recentes da comunidade'; + + @override + String get refreshBalance => 'Atualizar saldo'; + + @override + String get balanceRefreshed => 'Saldo atualizado'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart b/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart index f239c64b6..920c46ee1 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart @@ -18,6 +18,16 @@ class AppLocalizationsRu extends AppLocalizations { @override String get lnbitsAdminKey => 'Ключ администратора LNbits'; + @override + String get lnbitsKeyType => 'Тип ключа LNbits'; + + @override + String get lnbitsInvoiceReadKey => 'Ключ счетов/чтения LNbits'; + + @override + String get lnbitsReadOnlyDescription => + 'Кошелёк только для получения: просмотр баланса и истории, создание счетов. Отправка платежей отключена.'; + @override String get lnbitsUrl => 'URL LNbits'; @@ -1201,6 +1211,12 @@ class AppLocalizationsRu extends AppLocalizations { @override String get paste => 'Вставить'; + @override + String get clearInput => 'Очистить поле'; + + @override + String get pasteOrEnter => 'Вставить или ввести'; + @override String get fromYourProfile => 'Из вашего профиля'; @@ -1473,4 +1489,10 @@ class AppLocalizationsRu extends AppLocalizations { @override String get walletDetailCommunityReviews => 'Недавние отзывы сообщества'; + + @override + String get refreshBalance => 'Обновить баланс'; + + @override + String get balanceRefreshed => 'Баланс обновлён'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart b/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart index c26e8dcb9..7fca3c822 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart @@ -18,6 +18,16 @@ class AppLocalizationsSk extends AppLocalizations { @override String get lnbitsAdminKey => 'Kľúč správcu LNbits'; + @override + String get lnbitsKeyType => 'Typ kľúča LNbits'; + + @override + String get lnbitsInvoiceReadKey => 'Kľúč faktúr/čítania LNbits'; + + @override + String get lnbitsReadOnlyDescription => + 'Peňaženka len na prijímanie: zobrazuje zostatok a históriu a vytvára faktúry. Odosielanie platieb je vypnuté.'; + @override String get lnbitsUrl => 'URL LNbits'; @@ -1198,6 +1208,12 @@ class AppLocalizationsSk extends AppLocalizations { @override String get paste => 'Vložiť'; + @override + String get clearInput => 'Vymazať vstup'; + + @override + String get pasteOrEnter => 'Prilepiť alebo zadať'; + @override String get fromYourProfile => 'Z vášho profilu'; @@ -1470,4 +1486,10 @@ class AppLocalizationsSk extends AppLocalizations { @override String get walletDetailCommunityReviews => 'Najnovšie recenzie komunity'; + + @override + String get refreshBalance => 'Obnoviť zostatok'; + + @override + String get balanceRefreshed => 'Zostatok obnovený'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart b/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart index e14508d99..83bd9a10e 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart @@ -18,6 +18,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get lnbitsAdminKey => 'LNbits 管理员密钥'; + @override + String get lnbitsKeyType => 'LNbits 密钥类型'; + + @override + String get lnbitsInvoiceReadKey => 'LNbits 发票/只读密钥'; + + @override + String get lnbitsReadOnlyDescription => '仅收款钱包:可查看余额和历史记录并创建发票,无法发送付款。'; + @override String get lnbitsUrl => 'LNbits URL'; @@ -1179,6 +1188,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get paste => '粘贴'; + @override + String get clearInput => '清除输入'; + + @override + String get pasteOrEnter => '粘贴或输入'; + @override String get fromYourProfile => '来自您的个人资料'; @@ -1447,4 +1462,10 @@ class AppLocalizationsZh extends AppLocalizations { @override String get walletDetailCommunityReviews => '近期社区评论'; + + @override + String get refreshBalance => '刷新余额'; + + @override + String get balanceRefreshed => '余额已刷新'; } diff --git a/packages/ndk_flutter/lib/l10n/app_pl.arb b/packages/ndk_flutter/lib/l10n/app_pl.arb index d1cd8daf8..5c68b958f 100644 --- a/packages/ndk_flutter/lib/l10n/app_pl.arb +++ b/packages/ndk_flutter/lib/l10n/app_pl.arb @@ -2,6 +2,9 @@ "lnbitsWalletOption": "LNbits", "lnbitsConnectionInstructions": "W LNbits wybierz portfel, który chcesz połączyć, otwórz go, kliknij Dokumentacja API i skopiuj klucz administratora. Wklej go poniżej:", "lnbitsAdminKey": "Klucz administratora LNbits", + "lnbitsKeyType": "Typ klucza LNbits", + "lnbitsInvoiceReadKey": "Klucz faktur/odczytu LNbits", + "lnbitsReadOnlyDescription": "Portfel tylko do odbioru: wyświetla saldo i historię oraz tworzy faktury. Wysyłanie płatności jest wyłączone.", "lnbitsUrl": "Adres URL LNbits", "lnbitsCredentialsRequired": "Wprowadź klucz administratora i adres URL LNbits.", "lnbitsWalletAdded": "Portfel LNbits został dodany", @@ -473,6 +476,8 @@ "scanNwcInstructions": "Zeskanuj kod QR z aplikacji portfela NWC", "invalidNwcUri": "Nieprawidłowy URI NWC", "paste": "Wklej", + "clearInput": "Wyczyść pole", + "pasteOrEnter": "Wklej lub wpisz", "fromYourProfile": "Z twojego profilu", "orEnterManually": "Lub wprowadź ręcznie:", "budgetUsedOf": "Budżet: {used} / {total}", @@ -512,5 +517,7 @@ "walletName": "Nazwa portfela", "walletNameHint": "Wprowadź nazwę portfela", "save": "Zapisz", - "walletRenamed": "Zmieniono nazwę portfela" + "walletRenamed": "Zmieniono nazwę portfela", + "refreshBalance": "Odśwież saldo", + "balanceRefreshed": "Saldo odświeżone" } diff --git a/packages/ndk_flutter/lib/l10n/app_pt.arb b/packages/ndk_flutter/lib/l10n/app_pt.arb index 403594c58..8be4ec305 100644 --- a/packages/ndk_flutter/lib/l10n/app_pt.arb +++ b/packages/ndk_flutter/lib/l10n/app_pt.arb @@ -2,6 +2,9 @@ "lnbitsWalletOption": "LNbits", "lnbitsConnectionInstructions": "No LNbits, escolha a carteira que pretende ligar, abra-a, clique em Documentação da API e copie a chave de administrador. Cole-a abaixo:", "lnbitsAdminKey": "Chave de administrador LNbits", + "lnbitsKeyType": "Tipo de chave LNbits", + "lnbitsInvoiceReadKey": "Chave de fatura/leitura LNbits", + "lnbitsReadOnlyDescription": "Carteira apenas para receber: consulte saldo e histórico e crie faturas. O envio de pagamentos está desativado.", "lnbitsUrl": "URL do LNbits", "lnbitsCredentialsRequired": "Introduza a chave de administrador e o URL do LNbits.", "lnbitsWalletAdded": "Carteira LNbits adicionada", @@ -415,6 +418,8 @@ "scanNwcInstructions": "Leia o código QR da sua app de carteira NWC", "invalidNwcUri": "URI NWC inválido", "paste": "Colar", + "clearInput": "Limpar entrada", + "pasteOrEnter": "Colar ou digitar", "fromYourProfile": "Do seu perfil", "orEnterManually": "Ou introduza manualmente:", "renameWallet": "Renomear", @@ -430,5 +435,7 @@ "budgetWeekly": "Semanal", "budgetMonthly": "Mensal", "budgetYearly": "Anual", - "budgetNever": "Nunca" + "budgetNever": "Nunca", + "refreshBalance": "Atualizar saldo", + "balanceRefreshed": "Saldo atualizado" } diff --git a/packages/ndk_flutter/lib/l10n/app_pt_BR.arb b/packages/ndk_flutter/lib/l10n/app_pt_BR.arb index 5199170b8..9614061b3 100644 --- a/packages/ndk_flutter/lib/l10n/app_pt_BR.arb +++ b/packages/ndk_flutter/lib/l10n/app_pt_BR.arb @@ -2,6 +2,9 @@ "lnbitsWalletOption": "LNbits", "lnbitsConnectionInstructions": "No LNbits, escolha a carteira que deseja conectar, abra-a, clique em Documentação da API e copie a chave de administrador. Cole-a abaixo:", "lnbitsAdminKey": "Chave de administrador LNbits", + "lnbitsKeyType": "Tipo de chave LNbits", + "lnbitsInvoiceReadKey": "Chave de fatura/leitura LNbits", + "lnbitsReadOnlyDescription": "Carteira somente para receber: consulte saldo e histórico e crie faturas. O envio de pagamentos está desativado.", "lnbitsUrl": "URL do LNbits", "lnbitsCredentialsRequired": "Insira a chave de administrador e a URL do LNbits.", "lnbitsWalletAdded": "Carteira LNbits adicionada", @@ -415,6 +418,8 @@ "scanNwcInstructions": "Escaneie o QR code do seu app de carteira NWC", "invalidNwcUri": "URI NWC inválido", "paste": "Colar", + "clearInput": "Limpar entrada", + "pasteOrEnter": "Colar ou digitar", "fromYourProfile": "Do seu perfil", "orEnterManually": "Ou digite manualmente:", "renameWallet": "Renomear", @@ -430,5 +435,7 @@ "budgetWeekly": "Semanal", "budgetMonthly": "Mensal", "budgetYearly": "Anual", - "budgetNever": "Nunca" + "budgetNever": "Nunca", + "refreshBalance": "Atualizar saldo", + "balanceRefreshed": "Saldo atualizado" } diff --git a/packages/ndk_flutter/lib/l10n/app_ru.arb b/packages/ndk_flutter/lib/l10n/app_ru.arb index f16acac7f..af77208a8 100644 --- a/packages/ndk_flutter/lib/l10n/app_ru.arb +++ b/packages/ndk_flutter/lib/l10n/app_ru.arb @@ -2,6 +2,9 @@ "lnbitsWalletOption": "LNbits", "lnbitsConnectionInstructions": "В LNbits выберите кошелёк для подключения, откройте его, нажмите «Документация API» и скопируйте ключ администратора. Вставьте его ниже:", "lnbitsAdminKey": "Ключ администратора LNbits", + "lnbitsKeyType": "Тип ключа LNbits", + "lnbitsInvoiceReadKey": "Ключ счетов/чтения LNbits", + "lnbitsReadOnlyDescription": "Кошелёк только для получения: просмотр баланса и истории, создание счетов. Отправка платежей отключена.", "lnbitsUrl": "URL LNbits", "lnbitsCredentialsRequired": "Введите ключ администратора и URL LNbits.", "lnbitsWalletAdded": "Кошелёк LNbits добавлен", @@ -415,6 +418,8 @@ "scanNwcInstructions": "Отсканируйте QR-код из приложения кошелька NWC", "invalidNwcUri": "Неверный URI NWC", "paste": "Вставить", + "clearInput": "Очистить поле", + "pasteOrEnter": "Вставить или ввести", "fromYourProfile": "Из вашего профиля", "orEnterManually": "Или введите вручную:", "budgetUsedOf": "Бюджет: {used} / {total}", @@ -454,5 +459,7 @@ "walletName": "Название кошелька", "walletNameHint": "Введите название кошелька", "save": "Сохранить", - "walletRenamed": "Кошелек переименован" + "walletRenamed": "Кошелек переименован", + "refreshBalance": "Обновить баланс", + "balanceRefreshed": "Баланс обновлён" } diff --git a/packages/ndk_flutter/lib/l10n/app_sk.arb b/packages/ndk_flutter/lib/l10n/app_sk.arb index 1f20e71b8..3b0d90b39 100644 --- a/packages/ndk_flutter/lib/l10n/app_sk.arb +++ b/packages/ndk_flutter/lib/l10n/app_sk.arb @@ -2,6 +2,9 @@ "lnbitsWalletOption": "LNbits", "lnbitsConnectionInstructions": "V LNbits vyberte peňaženku, ktorú chcete pripojiť, otvorte ju, kliknite na Dokumentáciu API a skopírujte kľúč správcu. Vložte ho nižšie:", "lnbitsAdminKey": "Kľúč správcu LNbits", + "lnbitsKeyType": "Typ kľúča LNbits", + "lnbitsInvoiceReadKey": "Kľúč faktúr/čítania LNbits", + "lnbitsReadOnlyDescription": "Peňaženka len na prijímanie: zobrazuje zostatok a históriu a vytvára faktúry. Odosielanie platieb je vypnuté.", "lnbitsUrl": "URL LNbits", "lnbitsCredentialsRequired": "Zadajte kľúč správcu aj URL LNbits.", "lnbitsWalletAdded": "Peňaženka LNbits bola pridaná", @@ -473,6 +476,8 @@ "scanNwcInstructions": "Naskenujte QR kód z vašej NWC peňaženkovej aplikácie", "invalidNwcUri": "Neplatné NWC URI", "paste": "Vložiť", + "clearInput": "Vymazať vstup", + "pasteOrEnter": "Prilepiť alebo zadať", "fromYourProfile": "Z vášho profilu", "orEnterManually": "Alebo zadajte manuálne:", "renameWallet": "Premenovať", @@ -534,5 +539,7 @@ "description": "Number of restored proofs" } } - } + }, + "refreshBalance": "Obnoviť zostatok", + "balanceRefreshed": "Zostatok obnovený" } diff --git a/packages/ndk_flutter/lib/l10n/app_zh.arb b/packages/ndk_flutter/lib/l10n/app_zh.arb index 688814e96..b39c85698 100644 --- a/packages/ndk_flutter/lib/l10n/app_zh.arb +++ b/packages/ndk_flutter/lib/l10n/app_zh.arb @@ -2,6 +2,9 @@ "lnbitsWalletOption": "LNbits", "lnbitsConnectionInstructions": "在 LNbits 中选择并打开要连接的钱包,点击 API 文档并复制管理员密钥。粘贴到下方:", "lnbitsAdminKey": "LNbits 管理员密钥", + "lnbitsKeyType": "LNbits 密钥类型", + "lnbitsInvoiceReadKey": "LNbits 发票/只读密钥", + "lnbitsReadOnlyDescription": "仅收款钱包:可查看余额和历史记录并创建发票,无法发送付款。", "lnbitsUrl": "LNbits URL", "lnbitsCredentialsRequired": "请输入 LNbits 管理员密钥和 URL。", "lnbitsWalletAdded": "LNbits 钱包已添加", @@ -415,6 +418,8 @@ "scanNwcInstructions": "从您的NWC钱包应用扫描二维码", "invalidNwcUri": "无效的NWC URI", "paste": "粘贴", + "clearInput": "清除输入", + "pasteOrEnter": "粘贴或输入", "fromYourProfile": "来自您的个人资料", "orEnterManually": "或手动输入:", "budgetUsedOf": "预算:{used} / {total}", @@ -454,5 +459,7 @@ "walletName": "钱包名称", "walletNameHint": "输入钱包名称", "save": "保存", - "walletRenamed": "钱包已重命名" + "walletRenamed": "钱包已重命名", + "refreshBalance": "刷新余额", + "balanceRefreshed": "余额已刷新" } diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_add_wallet_dialogs.dart b/packages/ndk_flutter/lib/widgets/wallets/n_add_wallet_dialogs.dart index e9f1825ae..5fcee48de 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_add_wallet_dialogs.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_add_wallet_dialogs.dart @@ -36,12 +36,14 @@ class WalletInputScanResult { final WalletInputOrigin origin; final CashuMintSuggestion? cashuMintSuggestion; final LnBitsConnectionInput? lnBitsConnection; + final String? providerId; const WalletInputScanResult.value( this.value, { this.manuallyEntered = false, this.origin = WalletInputOrigin.scanner, this.cashuMintSuggestion, + this.providerId, }) : connectionStarted = false, lnBitsConnection = null; @@ -51,7 +53,8 @@ class WalletInputScanResult { connectionStarted = false, manuallyEntered = true, origin = WalletInputOrigin.walletChooser, - cashuMintSuggestion = null; + cashuMintSuggestion = null, + providerId = null; const WalletInputScanResult.connectionStarted() : value = null, @@ -59,7 +62,8 @@ class WalletInputScanResult { manuallyEntered = false, origin = WalletInputOrigin.walletChooser, cashuMintSuggestion = null, - lnBitsConnection = null; + lnBitsConnection = null, + providerId = null; } class LnBitsConnectionInput { @@ -67,12 +71,14 @@ class LnBitsConnectionInput { final String adminKey; final String? walletName; final String? remoteWalletId; + final bool readOnly; const LnBitsConnectionInput({ required this.url, required this.adminKey, this.walletName, this.remoteWalletId, + this.readOnly = false, }); } @@ -451,17 +457,20 @@ class NwcWalletAuthCoordinator { required Uri launchUri, required String callback, required String walletName, + String? providerId, }) async { _retryLaunch = () => connectWithUri( context, launchUri: launchUri, callback: callback, walletName: walletName, + providerId: providerId, ); _pendingSession = null; _pendingCallbackSession = _PendingNwcCallbackSession( returnTo: callback, walletName: walletName, + providerId: providerId, ); _markAwaiting(walletName); @@ -505,12 +514,17 @@ class NwcWalletAuthCoordinator { BuildContext context, { required AlbyGoConnectConfig config, required String walletName, + String? providerId, }) async { if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) return; final appKey = Bip340.generatePrivateKey(); - _retryLaunch = () => - connectWalletAuth(context, config: config, walletName: walletName); + _retryLaunch = () => connectWalletAuth( + context, + config: config, + walletName: walletName, + providerId: providerId, + ); final launchUri = buildNwcWalletAuthUri( appPubkey: appKey.publicKey, config: config, @@ -521,6 +535,7 @@ class NwcWalletAuthCoordinator { discoveryRelay: config.discoveryRelay, returnTo: config.callback, walletName: walletName, + providerId: providerId, ); _pendingCallbackSession = null; _markAwaiting(walletName); @@ -564,6 +579,7 @@ class NwcWalletAuthCoordinator { required String discoveryRelay, required String callback, required String walletName, + String? providerId, String? walletServicePubkey, Map additionalQueryParameters = const {}, }) async { @@ -575,6 +591,7 @@ class NwcWalletAuthCoordinator { discoveryRelay: discoveryRelay, callback: callback, walletName: walletName, + providerId: providerId, walletServicePubkey: walletServicePubkey, additionalQueryParameters: additionalQueryParameters, ); @@ -591,6 +608,7 @@ class NwcWalletAuthCoordinator { returnTo: callback, walletName: walletName, walletServicePubkey: walletServicePubkey, + providerId: providerId, ); _pendingCallbackSession = null; _markAwaiting(walletName); @@ -627,6 +645,7 @@ class NwcWalletAuthCoordinator { context, config: config, walletName: config.walletName, + providerId: 'alby', ); } @@ -645,6 +664,7 @@ class NwcWalletAuthCoordinator { _pendingCallbackSession = _PendingNwcCallbackSession( returnTo: config.callback, walletName: config.walletName, + providerId: 'alby', ); _markAwaiting(config.walletName); @@ -712,6 +732,7 @@ class NwcWalletAuthCoordinator { ndkFlutter, nwcUri: nwcUri, walletName: pendingWalletAuth.walletName, + providerId: pendingWalletAuth.providerId, ); _pendingSession = null; connectionState.value = WalletConnectionState.connected( @@ -755,6 +776,8 @@ class NwcWalletAuthCoordinator { pendingCallbackSession?.walletName ?? _pendingSession?.walletName ?? kDefaultAlbyGoConnectConfig.walletName, + providerId: + pendingCallbackSession?.providerId ?? _pendingSession?.providerId, ); connectionState.value = WalletConnectionState.connected( pendingCallbackSession?.walletName ?? @@ -865,6 +888,7 @@ class NwcWalletAuthCoordinator { ndkFlutter, nwcUri: constructedNwcUri, walletName: pendingSession.walletName, + providerId: pendingSession.providerId, ); _pendingSession = null; @@ -917,6 +941,7 @@ class NwcWalletAuthCoordinator { NdkFlutter ndkFlutter, { required String nwcUri, required String walletName, + String? providerId, }) async { final walletId = DateTime.now().millisecondsSinceEpoch.toString(); final nwcWallet = NwcWallet( @@ -924,6 +949,7 @@ class NwcWalletAuthCoordinator { name: walletName, supportedUnits: {'sat'}, nwcUrl: nwcUri, + providerId: providerId, ); await ndkFlutter.ndk.wallets.addWallet(nwcWallet); _lastConnectedWalletId = walletId; @@ -936,6 +962,7 @@ class _PendingNwcWalletAuthSession { final String returnTo; final String walletName; final String? walletServicePubkey; + final String? providerId; const _PendingNwcWalletAuthSession({ required this.appKey, @@ -943,16 +970,19 @@ class _PendingNwcWalletAuthSession { required this.returnTo, required this.walletName, this.walletServicePubkey, + this.providerId, }); } class _PendingNwcCallbackSession { final String returnTo; final String walletName; + final String? providerId; const _PendingNwcCallbackSession({ required this.returnTo, required this.walletName, + this.providerId, }); } @@ -1877,6 +1907,7 @@ class _WalletInputPreview { final Bolt12ResolvedOffer? resolvedOffer; final CashuMintInfo? mintInfo; final LnBitsConnectionInput? lnBitsConnection; + final String? providerId; const _WalletInputPreview({ required this.input, @@ -1890,6 +1921,7 @@ class _WalletInputPreview { this.resolvedOffer, this.mintInfo, this.lnBitsConnection, + this.providerId, }); } @@ -1969,6 +2001,7 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { manuallyEntered: result.manuallyEntered, origin: result.origin, cashuMintSuggestion: result.cashuMintSuggestion, + providerId: result.providerId, ); } return; @@ -2001,7 +2034,12 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { details: [ _WalletPreviewDetail(l10n.walletDetailType, l10n.lnbitsWalletOption), _WalletPreviewDetail(l10n.lnbitsUrl, validated.url), - _WalletPreviewDetail(l10n.lnbitsAdminKey, l10n.walletSecretHidden), + _WalletPreviewDetail( + validated.readOnly + ? l10n.lnbitsInvoiceReadKey + : l10n.lnbitsAdminKey, + l10n.walletSecretHidden, + ), if (validated.remoteWalletId?.isNotEmpty == true) _WalletPreviewDetail( l10n.walletDetailWalletId, @@ -2036,6 +2074,7 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { adminKey: adminKey, walletName: info.name, remoteWalletId: info.id, + readOnly: connection.readOnly, ); } @@ -2216,6 +2255,7 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { bool manuallyEntered = false, WalletInputOrigin origin = WalletInputOrigin.scanner, CashuMintSuggestion? cashuMintSuggestion, + String? providerId, }) async { final input = _normalizeWalletInput(rawInput); _setInput(input); @@ -2234,6 +2274,7 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { manuallyEntered, origin, cashuMintSuggestion, + providerId, ); if (!mounted) return; setState(() { @@ -2257,6 +2298,7 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { bool manuallyEntered, WalletInputOrigin origin, CashuMintSuggestion? cashuMintSuggestion, + String? providerId, ) async { final l10n = AppLocalizations.of(context)!; switch (kind) { @@ -2277,6 +2319,7 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { detectedKind: kind, walletType: WalletType.NWC, name: name, + providerId: providerId, details: [ _WalletPreviewDetail( l10n.walletDetailType, @@ -2548,6 +2591,7 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { LnBitsConnectionInput( url: input, adminKey: _lnBitsAdminKeyController.text.trim(), + readOnly: preview.lnBitsConnection?.readOnly ?? false, ), ); preview = _WalletInputPreview( @@ -2567,6 +2611,7 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { true, preview.origin, preview.cashuMintSuggestion, + preview.providerId, ); if (!mounted) return; setState(() => _preview = preview); @@ -2599,6 +2644,7 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { name: walletName, supportedUnits: const {'sat'}, nwcUrl: preview.input, + providerId: preview.providerId, ); await widget.ndkFlutter.ndk.wallets.addWallet(wallet); return wallet; @@ -2625,6 +2671,7 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { metadata: { LnBitsWallet.urlMetadataKey: connection.url, LnBitsWallet.adminKeyMetadataKey: connection.adminKey, + LnBitsWallet.readOnlyMetadataKey: connection.readOnly, LnBitsWallet.remoteWalletIdMetadataKey: ?connection.remoteWalletId, }, ); @@ -2863,6 +2910,34 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { ), const SizedBox(height: 22), if (preview.walletType == WalletType.LNBITS) ...[ + if (preview.lnBitsConnection?.readOnly == true) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colors.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.lock_outline, + color: colors.onPrimaryContainer, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + l10n.lnbitsReadOnlyDescription, + style: TextStyle( + color: colors.onPrimaryContainer, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], TextField( controller: _lnBitsAdminKeyController, enabled: !_isAdding, @@ -2871,7 +2946,9 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { autocorrect: false, decoration: InputDecoration( border: const OutlineInputBorder(), - labelText: l10n.lnbitsAdminKey, + labelText: preview.lnBitsConnection?.readOnly == true + ? l10n.lnbitsInvoiceReadKey + : l10n.lnbitsAdminKey, ), ), const SizedBox(height: 16), diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_nwc_wallet_icon.dart b/packages/ndk_flutter/lib/widgets/wallets/n_nwc_wallet_icon.dart new file mode 100644 index 000000000..2013124d1 --- /dev/null +++ b/packages/ndk_flutter/lib/widgets/wallets/n_nwc_wallet_icon.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:ndk/entities.dart'; + +class NNwcWalletIcon extends StatelessWidget { + final NwcWallet wallet; + final double size; + + const NNwcWalletIcon({super.key, required this.wallet, this.size = 32}); + + @override + Widget build(BuildContext context) { + return switch (wallet.providerId) { + 'alby' => _BrandIconFrame( + size: size, + backgroundColor: Colors.black, + child: SvgPicture.asset( + 'assets/images/albyhub.svg', + package: 'ndk_flutter', + width: size * 0.625, + height: size * 0.625, + ), + ), + 'coinos' => _BrandIconFrame( + size: size, + backgroundColor: Colors.white, + child: SvgPicture.asset( + 'assets/images/coinos.svg', + package: 'ndk_flutter', + width: size * 0.75, + height: size * 0.75, + ), + ), + _ => Image.asset( + 'assets/images/nwc.png', + package: 'ndk_flutter', + width: size, + height: size, + fit: BoxFit.contain, + ), + }; + } +} + +class _BrandIconFrame extends StatelessWidget { + final double size; + final Color backgroundColor; + final Widget child; + + const _BrandIconFrame({ + required this.size, + required this.backgroundColor, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(size / 4), + ), + child: Center(child: child), + ); + } +} 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 dfd854dae..e983308fc 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_actions.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_actions.dart @@ -4,6 +4,7 @@ import 'package:ndk_flutter/ndk_flutter.dart'; import '../../l10n/app_localizations.dart'; import 'n_cashu_mint_icon.dart'; +import 'n_nwc_wallet_icon.dart'; import 'wallet_action_dialogs.dart'; /// Card with Send/Receive actions and dialogs for a selected wallet. @@ -92,6 +93,7 @@ class _NWalletActionsState extends State final bool isCashu = selectedWallet is CashuWallet; final bool isNwc = selectedWallet is NwcWallet; + final bool isLnurl = selectedWallet is LnurlWallet; final bool isBolt12 = selectedWallet is Bolt12Wallet; final bool isLnBits = selectedWallet is LnBitsWallet; final bool canSend = selectedWallet.canSend; @@ -111,15 +113,7 @@ class _NWalletActionsState extends State if (isCashu) NCashuMintIcon(wallet: selectedWallet, size: 24) else if (isNwc) - Image.asset( - 'assets/images/nwc.png', - package: 'ndk_flutter', - width: 24, - height: 24, - errorBuilder: (context, error, stackTrace) { - return const Icon(Icons.cloud, color: Colors.blue); - }, - ) + NNwcWalletIcon(wallet: selectedWallet, size: 24) else if (isBolt12) const Icon(Icons.electric_bolt, color: Colors.green) else if (isLnBits) @@ -151,6 +145,23 @@ class _NWalletActionsState extends State const Divider(), const SizedBox(height: 8), ], + if ((isLnBits || isLnurl || isNwc) && canReceive && !canSend) ...[ + Row( + children: [ + Icon( + Icons.lock_outline, + size: 18, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + l10n.receiveOnlyWallet, + style: Theme.of(context).textTheme.labelLarge, + ), + ], + ), + const SizedBox(height: 12), + ], if (canSend || canReceive) Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, 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 3f5f90cb7..0cb23dcd5 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card.dart @@ -6,6 +6,7 @@ import 'package:ndk_flutter/ndk_flutter.dart'; import '../../l10n/app_localizations.dart'; import 'n_cashu_mint_icon.dart'; +import 'n_nwc_wallet_icon.dart'; import 'wallet_action_dialogs.dart'; /// Configuration for wallet type icons @@ -89,6 +90,7 @@ class _NWalletCardState extends State List? _customGradientColors; GetBudgetResponse? _budgetResponse; bool _isFetchingBudget = false; + bool _isRefreshingBalance = false; @override NdkFlutter get ndkFlutter => widget.ndkFlutter; @@ -260,8 +262,10 @@ class _NWalletCardState extends State final bool canShowNwcBalance = !isNwc || nwcPermissions.contains(NwcMethod.GET_BALANCE.name); final bool showBudgetInfo = isNwc && _shouldShowBudgetInfo(); - final bool isNwcReceiveOnly = - isNwc && widget.wallet.canReceive && !widget.wallet.canSend; + final bool showReceiveOnlyLabel = + (isNwc || isLnurl || isLnBits) && + widget.wallet.canReceive && + !widget.wallet.canSend; final String walletName; if (isCashu) { @@ -376,6 +380,11 @@ class _NWalletCardState extends State wallet: widget.wallet as CashuWallet, size: iconConfig.iconSize, ) + : isNwc + ? NNwcWalletIcon( + wallet: widget.wallet as NwcWallet, + size: iconConfig.iconSize, + ) : isLnBits ? NLnBitsIcon(size: iconConfig.iconSize) : defaultAssetName != null @@ -465,6 +474,51 @@ class _NWalletCardState extends State mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ mainIcon, + if (showReceiveOnlyLabel) + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 12, right: 28), + child: Align( + alignment: Alignment.centerLeft, + child: Tooltip( + message: l10n.receiveOnlyWallet, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.white.withAlpha(32), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.lock_outline, + color: Colors.white, + size: 12, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + l10n.receiveOnlyWallet, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), // if (widget.isSelected) // Container( // margin: const EdgeInsets.only(right: 32), @@ -512,16 +566,6 @@ class _NWalletCardState extends State overflow: TextOverflow.ellipsis, ), ], - if (isNwcReceiveOnly) ...[ - const SizedBox(height: 4), - Text( - l10n.receiveOnlyWallet, - style: TextStyle( - color: Colors.white.withAlpha(200), - fontSize: 12, - ), - ), - ], SizedBox(height: showBudgetInfo ? 4 : 16), isLnurl ? _buildLnurlInfo( @@ -875,7 +919,7 @@ class _NWalletCardState extends State } else if (isBolt12) { return [const Color(0xFF1B5E20), const Color(0xFF43A047)]; } else if (isLnBits) { - return [const Color(0xFF512DA8), const Color(0xFF7E57C2)]; + return [const Color(0xFF21172F), const Color(0xFF3B2853)]; } else { return [Colors.grey[700]!, Colors.grey[400]!]; } @@ -1064,6 +1108,7 @@ class _NWalletCardState extends State name: w.name, supportedUnits: w.supportedUnits, nwcUrl: w.nwcUrl, + providerId: w.providerId, metadata: updatedMetadata, ); } else if (widget.wallet is LnurlWallet) { @@ -1122,11 +1167,6 @@ class _NWalletCardState extends State return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - l10n.receiveOnlyWallet, - style: TextStyle(color: Colors.white.withAlpha(200), fontSize: 12), - ), - const SizedBox(height: 4), Text( l10n.receiveRange( lnWallet.minSendable! ~/ 1000, @@ -1262,12 +1302,45 @@ class _NWalletCardState extends State fontSize: unitFontSize, ), ), + if (widget.wallet is LnBitsWallet || + widget.wallet is NwcWallet) ...[ + const Spacer(), + IconButton( + tooltip: l10n.refreshBalance, + color: Colors.white, + visualDensity: VisualDensity.compact, + onPressed: _isRefreshingBalance ? null : _refreshBalance, + icon: _isRefreshingBalance + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.refresh), + ), + ], ], ); }, ); } + Future _refreshBalance() async { + setState(() => _isRefreshingBalance = true); + try { + await widget.ndkFlutter.ndk.wallets.refreshBalance(widget.wallet.id); + if (mounted) { + displaySuccess(AppLocalizations.of(context)!.balanceRefreshed); + } + } catch (error) { + if (mounted) displayError(error.toString()); + } finally { + if (mounted) setState(() => _isRefreshingBalance = false); + } + } + bool _shouldShowBudgetInfo() { final budget = _budgetResponse; if (budget == null) return false; diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card_list.dart b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card_list.dart index 1b71d67e2..fe22f53ec 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card_list.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card_list.dart @@ -123,6 +123,7 @@ class _NWalletCardListState extends State { name: wallet.name, supportedUnits: wallet.supportedUnits, nwcUrl: wallet.nwcUrl, + providerId: wallet.providerId, metadata: metadata, ); } diff --git a/packages/ndk_flutter/lib/widgets/wallets/wallet_action_dialogs.dart b/packages/ndk_flutter/lib/widgets/wallets/wallet_action_dialogs.dart index 81087b30d..b0fba7741 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/wallet_action_dialogs.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/wallet_action_dialogs.dart @@ -174,7 +174,9 @@ mixin WalletActionDialogsMixin on State { void showReceiveFlow(BuildContext context, Wallet wallet) { if (wallet is Bolt12Wallet) { _showBolt12OfferDialog(context, wallet); - } else if (wallet is NwcWallet || wallet is LnurlWallet) { + } else if (wallet is CashuWallet) { + _showReceiveDialog(context, wallet); + } else if (wallet.supportsBolt11InvoiceReceive) { _showCreateInvoiceDialog(context, wallet); } else { _showReceiveDialog(context, wallet); @@ -546,7 +548,7 @@ mixin WalletActionDialogsMixin on State { onTap: () => Navigator.pop(sheetContext, _WalletSendAction.invoice), ), - ] else if (wallet is NwcWallet) ...[ + ] else if (wallet.supportsBolt11InvoicePay) ...[ ListTile( leading: const Icon(Icons.flash_on), title: Text(l10n.payInvoiceTitle), @@ -981,13 +983,22 @@ mixin WalletActionDialogsMixin on State { break; } } - } else if (wallet is NwcWallet) { + } else if (wallet.supportsBolt11InvoicePay) { final response = await ndkFlutter.ndk.wallets.send( walletId: wallet.id, invoice: invoice, ); if (response.errorCode == null && response.preimage != null) { + if (wallet is LnBitsWallet) { + try { + await ndkFlutter.ndk.wallets.refreshBalance( + wallet.id, + ); + } catch (_) { + // Payment succeeded; background polling will retry. + } + } if (!mounted) return; navigator.pop(); scaffoldMessenger.showSnackBar( @@ -1125,7 +1136,7 @@ mixin WalletActionDialogsMixin on State { scaffoldMessenger, ); } - } else if (wallet is NwcWallet || wallet is LnurlWallet) { + } else if (wallet.supportsBolt11InvoiceReceive) { final invoice = await ndkFlutter.ndk.wallets.receive( walletId: wallet.id, amountSats: amount, diff --git a/packages/sample-app/lib/nwc_qr_scanner.dart b/packages/sample-app/lib/nwc_qr_scanner.dart index 48df27b29..6f60f417e 100644 --- a/packages/sample-app/lib/nwc_qr_scanner.dart +++ b/packages/sample-app/lib/nwc_qr_scanner.dart @@ -116,10 +116,8 @@ class _WalletQrScannerDialogState extends State<_WalletQrScannerDialog> { setState(() => _errorMessage = error.toString()); } - Future _pasteFromClipboard() async { - final clipboardData = await Clipboard.getData(Clipboard.kTextPlain); - if (!mounted) return; - await _showManualInput(initialValue: clipboardData?.text ?? ''); + Future _openManualInput() async { + await _showManualInput(); } Future _showManualInput({ @@ -307,10 +305,12 @@ class _WalletQrScannerDialogState extends State<_WalletQrScannerDialog> { children: [ Expanded( child: ElevatedButton.icon( - onPressed: - _hasScanned ? null : _pasteFromClipboard, + onPressed: _hasScanned ? null : _openManualInput, icon: const Icon(Icons.paste), - label: Text(l10n.paste), + label: FittedBox( + fit: BoxFit.scaleDown, + child: Text(l10n.pasteOrEnter), + ), style: ElevatedButton.styleFrom( backgroundColor: Theme.of( context, @@ -578,11 +578,26 @@ class _ManualWalletInputDialogState extends State<_ManualWalletInputDialog> { } } + Future _pasteInput() async { + final clipboardData = await Clipboard.getData(Clipboard.kTextPlain); + if (!mounted) return; + final value = clipboardData?.text?.trim() ?? ''; + _controller.text = value; + _controller.selection = TextSelection.collapsed(offset: value.length); + setState(() => _kind = classifyWalletInput(value)); + } + + void _clearInput() { + _controller.clear(); + setState(() => _kind = null); + } + @override Widget build(BuildContext context) { final l10n = ndk_l10n.AppLocalizations.of(context)!; final kind = _kind; final hasInput = _controller.text.trim().isNotEmpty; + final isNwcInput = kind == WalletInputKind.nwc; return AlertDialog( title: Text(widget.nwcOnly ? l10n.manualNwcConnection : l10n.walletInput), @@ -596,11 +611,11 @@ class _ManualWalletInputDialogState extends State<_ManualWalletInputDialog> { controller: _controller, autofocus: true, autocorrect: false, - obscureText: kind == WalletInputKind.nwc, - enableSuggestions: kind != WalletInputKind.nwc, + obscureText: isNwcInput, + enableSuggestions: !isNwcInput, keyboardType: TextInputType.url, - minLines: 2, - maxLines: 4, + minLines: isNwcInput ? 1 : 2, + maxLines: isNwcInput ? 1 : 4, onChanged: (value) { setState(() => _kind = classifyWalletInput(value)); }, @@ -609,8 +624,16 @@ class _ManualWalletInputDialogState extends State<_ManualWalletInputDialog> { hintText: widget.nwcOnly ? l10n.nwcConnectionUriHint : widget.supportedInputDescription, - suffixIcon: widget.nwcOnly - ? IconButton( + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: _pasteInput, + tooltip: l10n.paste, + icon: const Icon(Icons.content_paste_outlined), + ), + if (widget.nwcOnly) + IconButton( onPressed: _isScanningQr ? null : _scanQrCode, tooltip: l10n.scanWalletQrCode, icon: _isScanningQr @@ -621,8 +644,15 @@ class _ManualWalletInputDialogState extends State<_ManualWalletInputDialog> { ), ) : const Icon(Icons.qr_code_scanner), - ) - : null, + ), + if (hasInput) + IconButton( + onPressed: _clearInput, + tooltip: l10n.clearInput, + icon: const Icon(Icons.clear), + ), + ], + ), ), ), if (widget.nwcOnly && widget.connectWalletApp != null) ...[ @@ -1032,9 +1062,22 @@ class _LnBitsConnectionDialogState extends State<_LnBitsConnectionDialog> { final _adminKeyController = TextEditingController(); final _urlController = TextEditingController(text: 'https://'); bool _showAdminKey = false; + bool _readOnly = false; bool _isValidating = false; String? _error; + Future _scanInto(TextEditingController controller) async { + final value = await showDialog( + context: context, + builder: (_) => const _QrCodeScannerDialog(), + ); + if (!mounted || value == null) return; + final normalized = value.trim(); + controller.text = normalized; + controller.selection = TextSelection.collapsed(offset: normalized.length); + setState(() => _error = null); + } + @override void dispose() { _adminKeyController.dispose(); @@ -1050,7 +1093,11 @@ class _LnBitsConnectionDialogState extends State<_LnBitsConnectionDialog> { setState(() => _error = l10n.lnbitsCredentialsRequired); return; } - final input = LnBitsConnectionInput(url: url, adminKey: adminKey); + final input = LnBitsConnectionInput( + url: url, + adminKey: adminKey, + readOnly: _readOnly, + ); final validate = widget.validate; if (validate == null) { Navigator.of(context).pop(input); @@ -1092,6 +1139,43 @@ class _LnBitsConnectionDialogState extends State<_LnBitsConnectionDialog> { children: [ Text(l10n.lnbitsConnectionInstructions), const SizedBox(height: 20), + DropdownButtonFormField( + initialValue: _readOnly, + isExpanded: true, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.lnbitsKeyType, + ), + items: [ + DropdownMenuItem( + value: false, + child: Text(l10n.lnbitsAdminKey), + ), + DropdownMenuItem( + value: true, + child: Text(l10n.lnbitsInvoiceReadKey), + ), + ], + onChanged: _isValidating + ? null + : (value) => setState(() => _readOnly = value ?? false), + ), + if (_readOnly) ...[ + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.lock_outline, + size: 20, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded(child: Text(l10n.lnbitsReadOnlyDescription)), + ], + ), + ], + const SizedBox(height: 16), TextField( controller: _adminKeyController, enabled: !_isValidating, @@ -1100,14 +1184,27 @@ class _LnBitsConnectionDialogState extends State<_LnBitsConnectionDialog> { autocorrect: false, decoration: InputDecoration( border: const OutlineInputBorder(), - labelText: l10n.lnbitsAdminKey, - suffixIcon: IconButton( - onPressed: () => setState(() { - _showAdminKey = !_showAdminKey; - }), - icon: Icon( - _showAdminKey ? Icons.visibility_off : Icons.visibility, - ), + labelText: + _readOnly ? l10n.lnbitsInvoiceReadKey : l10n.lnbitsAdminKey, + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: _isValidating + ? null + : () => _scanInto(_adminKeyController), + tooltip: l10n.scanWalletQrCode, + icon: const Icon(Icons.qr_code_scanner), + ), + IconButton( + onPressed: () => setState(() { + _showAdminKey = !_showAdminKey; + }), + icon: Icon( + _showAdminKey ? Icons.visibility_off : Icons.visibility, + ), + ), + ], ), ), ), @@ -1120,6 +1217,12 @@ class _LnBitsConnectionDialogState extends State<_LnBitsConnectionDialog> { decoration: InputDecoration( border: const OutlineInputBorder(), labelText: l10n.lnbitsUrl, + suffixIcon: IconButton( + onPressed: + _isValidating ? null : () => _scanInto(_urlController), + tooltip: l10n.scanWalletQrCode, + icon: const Icon(Icons.qr_code_scanner), + ), ), ), if (_error != null) ...[ @@ -1435,6 +1538,7 @@ class _AlbyChooserDialog extends StatelessWidget { result!.value, manuallyEntered: true, origin: WalletInputOrigin.walletChooser, + providerId: 'alby', ), ); } @@ -1459,6 +1563,7 @@ class _AlbyChooserDialog extends StatelessWidget { onTap: _albyCloudOption == null ? null : () => _openCloud(context), ), + const SizedBox(height: 16), ListTile( leading: const _AlbyGoIcon(), title: Text(l10n.albyGoOption), @@ -1467,6 +1572,7 @@ class _AlbyChooserDialog extends StatelessWidget { onTap: _albyGoOption == null ? null : () => _connectAlbyGo(context), ), + const SizedBox(height: 16), ListTile( leading: const _NwcIcon(), title: Text(l10n.manualNwcConnection), diff --git a/packages/sample-app/lib/wallets.dart b/packages/sample-app/lib/wallets.dart index 18b71fa6b..fba646c1e 100644 --- a/packages/sample-app/lib/wallets.dart +++ b/packages/sample-app/lib/wallets.dart @@ -97,6 +97,7 @@ class WalletsPageState extends State with WidgetsBindingObserver { discoveryRelay: kDefaultAlbyGoConnectConfig.discoveryRelay, callback: _sampleCallback, walletName: 'Alby Cloud', + providerId: 'alby', additionalQueryParameters: const { 'return_to': _sampleCallback, }, @@ -114,6 +115,7 @@ class WalletsPageState extends State with WidgetsBindingObserver { discoveryRelay: _coinosRelay, callback: _sampleCallback, walletName: 'Coinos', + providerId: 'coinos', walletServicePubkey: _coinosWalletServicePubkey, ); }, From 1db0a4cc867960922f353d4e096fec8ee188c182 Mon Sep 17 00:00:00 2001 From: fmar Date: Sat, 12 Sep 2026 14:55:56 +0200 Subject: [PATCH 21/22] cashu logo grey background 2 --- .../cashu/cashu_wallet_provider.dart | 18 ++-------- .../entities/cashu_wallet_provider_test.dart | 25 ++++++++++++++ .../widgets/wallets/n_cashu_mint_icon.dart | 33 +++++++++++-------- 3 files changed, 47 insertions(+), 29 deletions(-) create mode 100644 packages/ndk/test/entities/cashu_wallet_provider_test.dart diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/cashu/cashu_wallet_provider.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/cashu/cashu_wallet_provider.dart index d8caa8fd3..7d34f0743 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/providers/cashu/cashu_wallet_provider.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/cashu/cashu_wallet_provider.dart @@ -185,21 +185,9 @@ class CashuWalletProvider implements WalletProvider { } @override - Stream> get discoveredWallets { - return _cashuUseCase.knownMints.map((mints) { - return mints - .map( - (mint) => CashuWallet( - id: mint.urls.first, - name: mint.name ?? mint.urls.first, - supportedUnits: mint.supportedUnits, - mintUrl: mint.urls.first, - mintInfo: mint, - ), - ) - .toList(); - }); - } + // Mint metadata is a cache, not proof of user-approved wallet membership. + // Cashu wallets are added explicitly and restored from WalletsRepo. + Stream> get discoveredWallets => Stream.value(const []); @override Future receive(Wallet wallet, int amountSats) async { diff --git a/packages/ndk/test/entities/cashu_wallet_provider_test.dart b/packages/ndk/test/entities/cashu_wallet_provider_test.dart new file mode 100644 index 000000000..f3c214a2e --- /dev/null +++ b/packages/ndk/test/entities/cashu_wallet_provider_test.dart @@ -0,0 +1,25 @@ +import 'package:ndk/entities.dart'; +import 'package:ndk/ndk.dart'; +import 'package:test/test.dart'; + +import '../cashu/cashu_test_tools.dart'; + +void main() { + test('cached mint metadata does not discover a Cashu wallet', () async { + const mintUrl = 'https://mint.example.com'; + final cache = MemCacheManager(); + await cache.saveMintInfo( + mintInfo: CashuMintInfo( + name: 'Example Mint', + nuts: const {}, + urls: const [mintUrl], + ), + ); + final cashu = CashuTestTools.mockHttpCashu(customCache: cache); + await cashu.knownMints.firstWhere((mints) => mints.isNotEmpty); + + final provider = CashuWalletProvider(cashu); + + expect(await provider.discoveredWallets.first, isEmpty); + }); +} diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_cashu_mint_icon.dart b/packages/ndk_flutter/lib/widgets/wallets/n_cashu_mint_icon.dart index 02c665135..d88ad9481 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_cashu_mint_icon.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_cashu_mint_icon.dart @@ -18,28 +18,33 @@ class NCashuMintIcon extends StatelessWidget { return Image.asset( 'assets/images/cashu.png', package: 'ndk_flutter', - width: size, - height: size, fit: BoxFit.contain, errorBuilder: (_, _, _) => - Icon(Icons.account_balance_wallet, color: Colors.orange, size: size), + const Icon(Icons.account_balance_wallet, color: Colors.orange), ); } @override Widget build(BuildContext context) { final iconUrl = wallet.mintInfo.iconUrl?.trim(); - return ClipRRect( - borderRadius: borderRadius, - child: iconUrl?.isNotEmpty == true - ? Image.network( - iconUrl!, - width: size, - height: size, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => _fallback(), - ) - : _fallback(), + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: const Color(0xFFE0E0E0), + borderRadius: borderRadius, + border: Border.all(color: Colors.white.withAlpha(120)), + ), + child: ClipRRect( + borderRadius: borderRadius, + child: iconUrl?.isNotEmpty == true + ? Image.network( + iconUrl!, + fit: BoxFit.contain, + errorBuilder: (_, _, _) => _fallback(), + ) + : _fallback(), + ), ); } } From fb82b6ad74032a576bc5db79ea30807011112cb5 Mon Sep 17 00:00:00 2001 From: fmar Date: Sat, 12 Sep 2026 22:01:02 +0200 Subject: [PATCH 22/22] improve wallet connection detection --- doc/ndk_flutter/qr-scanner.md | 52 +- .../lnurl/lnurl_wallet_provider.dart | 31 +- .../wallet/providers/nwc/nwc_wallet.dart | 5 + .../providers/nwc/nwc_wallet_provider.dart | 1 + .../cashu/cashu_mint_recommendations.dart | 22 +- .../lib/domain_layer/usecases/nwc/nwc.dart | 27 +- .../usecases/wallets/wallets.dart | 30 + .../ndk/test/entities/nwc_wallet_test.dart | 19 + .../ndk/test/usecases/lnurl/lnurl_test.dart | 38 + packages/ndk_flutter/README.md | 40 + .../android/src/main/AndroidManifest.xml | 1 + .../ndk_flutter/assets/images/albyhub.svg | 2 +- packages/ndk_flutter/lib/l10n/app_de.arb | 2 + packages/ndk_flutter/lib/l10n/app_en.arb | 8 + packages/ndk_flutter/lib/l10n/app_es.arb | 2 + packages/ndk_flutter/lib/l10n/app_fi.arb | 2 + packages/ndk_flutter/lib/l10n/app_fr.arb | 2 + packages/ndk_flutter/lib/l10n/app_it.arb | 2 + packages/ndk_flutter/lib/l10n/app_ja.arb | 2 + .../lib/l10n/app_localizations.dart | 12 + .../lib/l10n/app_localizations_de.dart | 7 + .../lib/l10n/app_localizations_en.dart | 7 + .../lib/l10n/app_localizations_es.dart | 7 + .../lib/l10n/app_localizations_fi.dart | 7 + .../lib/l10n/app_localizations_fr.dart | 7 + .../lib/l10n/app_localizations_it.dart | 7 + .../lib/l10n/app_localizations_ja.dart | 7 + .../lib/l10n/app_localizations_pl.dart | 7 + .../lib/l10n/app_localizations_pt.dart | 14 + .../lib/l10n/app_localizations_ru.dart | 7 + .../lib/l10n/app_localizations_sk.dart | 7 + .../lib/l10n/app_localizations_zh.dart | 6 + packages/ndk_flutter/lib/l10n/app_pl.arb | 2 + packages/ndk_flutter/lib/l10n/app_pt.arb | 2 + packages/ndk_flutter/lib/l10n/app_pt_BR.arb | 2 + packages/ndk_flutter/lib/l10n/app_ru.arb | 2 + packages/ndk_flutter/lib/l10n/app_sk.arb | 2 + packages/ndk_flutter/lib/l10n/app_zh.arb | 2 + .../widgets/wallets/n_add_wallet_dialogs.dart | 1157 +++++++---- .../widgets/wallets/n_nwc_wallet_icon.dart | 2 +- .../lib/widgets/wallets/n_wallet_card.dart | 170 +- .../wallets/n_wallet_input_dialog.dart | 1799 ++++++++++++++++ .../lib/widgets/wallets/n_wallets.dart | 34 +- packages/ndk_flutter/lib/widgets/widgets.dart | 1 + .../test/wallet_input_classifier_test.dart | 178 ++ .../test/wallet_input_dialog_test.dart | 350 ++++ .../test/wallet_scan_flow_test.dart | 89 + packages/sample-app/.gitignore | 1 + packages/sample-app/lib/linux_qr_scanner.dart | 212 ++ packages/sample-app/lib/nwc_qr_scanner.dart | 1808 +---------------- packages/sample-app/lib/wallets.dart | 52 +- packages/sample-app/pubspec.lock | 6 +- packages/sample-app/pubspec.yaml | 3 +- .../test/linux_qr_scanner_test.dart | 69 + 54 files changed, 4002 insertions(+), 2331 deletions(-) create mode 100644 packages/ndk_flutter/lib/widgets/wallets/n_wallet_input_dialog.dart create mode 100644 packages/ndk_flutter/test/wallet_input_dialog_test.dart create mode 100644 packages/ndk_flutter/test/wallet_scan_flow_test.dart create mode 100644 packages/sample-app/lib/linux_qr_scanner.dart create mode 100644 packages/sample-app/test/linux_qr_scanner_test.dart diff --git a/doc/ndk_flutter/qr-scanner.md b/doc/ndk_flutter/qr-scanner.md index 5fce8991d..29e07ae96 100644 --- a/doc/ndk_flutter/qr-scanner.md +++ b/doc/ndk_flutter/qr-scanner.md @@ -11,10 +11,9 @@ offers, BIP321 URIs, and HTTPS Cashu mint URLs from one QR scanner. `ndk_flutter` does **not** bundle a camera/scanner dependency. Instead you provide your own scanner, so you stay in control of the camera plugin, runtime permissions, and the UI. When you -don't provide one, the scan button is hidden and users can still paste a value manually. -On Android and iOS the scanner opens when the user enters the add-wallet flow. Set -`openScannerOnAdd: false` to start on the unified choices screen instead. Your scanner remains -responsible for camera-permission rationale and denial handling. +don't provide one, users can still paste a value manually. The shared wallet-input screen is +the sole add-wallet entry point; the previous intermediate Add Wallet screen no longer exists. +Your scanner remains responsible for camera-permission rationale and denial handling. ## Provide a scanner @@ -50,8 +49,8 @@ active. Keep the scanner open through `awaitingReturn`, `connecting`, and `faile `retryPendingConnection` and `cancelPendingConnection` for retry and back actions. Return `WalletInputScanResult.connectionStarted()` after the state reaches `connected`. -The widget classifies and validates scanned values. Your callback returns -Return scanned values as `WalletInputScanResult.value(rawText)`. For pasted or +The widget classifies and validates scanned values. Return scanned values as +`WalletInputScanResult.value(rawText)`. For pasted or typed values, pass `manuallyEntered: true` so confirmation allows editing. Legacy `nwcUriScanner` and `bolt12InputScanner` callbacks remain available on their type-specific dialogs. @@ -61,6 +60,22 @@ type-specific dialogs. On Android and iOS, the unified flow includes the standard NWC wallet chooser and Alby Go. Add installed or web wallet integrations with `nwcConnectionOptions`: +Alby Go uses the branded `nostr+walletauth+alby://` NWC-08 flow. If direct app launch +fails because Alby Go is not installed, the same authorization request is shown as a QR +code. Web and desktop platforms show this QR directly for scanning with Alby Go on a phone. +The client keeps its generated secret locally, verifies any returned `state` tag, +discovers the wallet-service public key from its kind `13194` info event, and honors any +wallet-service `relay` tag. + +Wallet-auth requests include `state`. For compatibility with deployed Alby implementations, +responses and info events may omit it; a present but mismatched state is always rejected. +Separate-phone QR requests omit `return_to` because no same-device callback is possible. + +Coinos uses its fixed service public key and `wss://relay.coinos.io`. Because its info event +is not addressed to the generated client key, the client subscribes to info events from that +fixed service key and keeps validating later events until the approved connection works. +No manual confirmation is required. + ```dart NWallets( ndkFlutter: ndkFlutter, @@ -86,7 +101,8 @@ NWallets( ``` Client-key web wallets can receive configurable app metadata and a freshly generated public -key. Complete pending authorization when the host app resumes: +key. Default Alby Cloud, Alby Go, and Coinos connections keep a live discovery dialog open +until a usable info event arrives or the user cancels: ```dart NwcConnectionOption( @@ -102,29 +118,33 @@ NwcConnectionOption( walletName: 'Coinos', walletServicePubkey: 'ba80990666ef0b6f4ba5059347beb13242921e54669e680064ca755256a1e3a6', + allowUntaggedInfoEvent: true, ); }, ) ``` -Call `NWalletsState.resumePendingWalletAuth()` from the app's resumed lifecycle callback. +Call `NWalletsState.resumePendingWalletAuth()` from mobile resumed lifecycle callbacks. +Legacy providers without client-tagged discovery events are revalidated every five +seconds while their connection screen remains open. -Use the standard installed-wallet flow for any app that handles `nostr+walletauth://`: +Use NWC-07 callback flow to launch `nostrnwc://connect`. Android resolves it using +normal system intent handling, including user defaults and multiple compatible apps: ```dart -return coordinator.connectWalletAuth( +return coordinator.connectInstalledWallet( context, config: const AlbyGoConnectConfig( appName: 'My app', appIconUrl: 'https://example.com/icon.png', callback: 'myapp://nwc', ), - walletName: 'NWC', ); ``` -Forward callback URLs to `NWalletsState.onProtocolUrlReceived`. Provider authorization URLs -must return a `nostr+walletconnect://` value in a callback query parameter. +Forward callback URLs to `NWalletsState.onProtocolUrlReceived`. Wallet-auth callback results +return `relay_url` and `wallet_pubkey`; when `state` is present, it must match. Legacy providers +may return a `nostr+walletconnect://` value in a callback query parameter. ## Example: scanning with mobile_scanner @@ -140,7 +160,5 @@ The callback just opens a dialog that wraps the camera view and pops the first d :::code source="../../packages/sample-app/lib/nwc_qr_scanner.dart" language="dart" range="7-12" title="scanWalletInput callback" ::: -The full dialog lives in -[`packages/sample-app/lib/nwc_qr_scanner.dart`](https://github.com/relaystr/ndk/blob/master/packages/sample-app/lib/nwc_qr_scanner.dart). -It adds a camera preview, paste and manual-entry fallbacks, wallet connection choices, error -handling, and a desktop/web-safe layout. +`packages/sample-app/lib/nwc_qr_scanner.dart` only adapts the host camera implementation. +Wallet choices, paste/manual input, errors, and layout live in `ndk_flutter`. diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/lnurl/lnurl_wallet_provider.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/lnurl/lnurl_wallet_provider.dart index 7a6a0be0f..913c391d3 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/providers/lnurl/lnurl_wallet_provider.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/lnurl/lnurl_wallet_provider.dart @@ -70,21 +70,22 @@ class LnurlWalletProvider implements WalletProvider { @override Future initialize(Wallet wallet) async { final lnurlWallet = wallet as LnurlWallet; - if (!lnurlWallet.isMetadataValid) { - final response = await _fetchAndCacheMetadata(lnurlWallet); - // Return updated wallet with fetched metadata - return LnurlWallet( - id: lnurlWallet.id, - name: lnurlWallet.name, - supportedUnits: lnurlWallet.supportedUnits, - identifier: lnurlWallet.identifier, - lnurlPayUrl: lnurlWallet.lnurlPayUrl, - minSendable: response.minSendable, - maxSendable: response.maxSendable, - metadataFetchedAt: DateTime.now().millisecondsSinceEpoch, - ); - } - return null; // No update needed + // Always contact the endpoint. Cached metadata cannot prove the remote + // LNURL service is still reachable when reconnecting an existing wallet. + final response = await _fetchAndCacheMetadata(lnurlWallet); + if (lnurlWallet.isMetadataValid) return null; + + return LnurlWallet( + id: lnurlWallet.id, + name: lnurlWallet.name, + supportedUnits: lnurlWallet.supportedUnits, + identifier: lnurlWallet.identifier, + lnurlPayUrl: lnurlWallet.lnurlPayUrl, + minSendable: response.minSendable, + maxSendable: response.maxSendable, + metadataFetchedAt: DateTime.now().millisecondsSinceEpoch, + metadata: lnurlWallet.metadata, + ); } @override diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart index 231fc186a..a5b7d070d 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart @@ -11,6 +11,8 @@ import 'package:rxdart/rxdart.dart'; class NwcWallet extends Wallet { static const String kPermissionsMetadataKey = 'permissions'; static const String kProviderIdMetadataKey = 'providerId'; + static const String kRequireAuthenticatedResponseMetadataKey = + 'requireAuthenticatedResponse'; final String nwcUrl; final Set cachedPermissions; @@ -27,6 +29,9 @@ class NwcWallet extends Wallet { bool isConnected() => connection != null; + bool get requireAuthenticatedResponse => + metadata[kRequireAuthenticatedResponseMetadataKey] == true; + NwcWallet({ required super.id, required super.name, diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet_provider.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet_provider.dart index f22618ce7..21c2e0149 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet_provider.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet_provider.dart @@ -386,6 +386,7 @@ class NwcWalletProvider implements WalletProvider { wallet.connection = await _nwcUseCase.connect( wallet.nwcUrl, doGetInfoMethod: true, + requireGetInfoResponse: wallet.requireAuthenticatedResponse, ); // Auto-refresh balance whenever the wallet reports a payment notification diff --git a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_mint_recommendations.dart b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_mint_recommendations.dart index 2a9f63f35..b76d198e0 100644 --- a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_mint_recommendations.dart +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_mint_recommendations.dart @@ -48,8 +48,13 @@ class CashuMintRecommendations { _inFlight = request; try { final result = await request; - _cache = result; - _cachedAt = DateTime.now(); + // An empty result commonly means every relay was temporarily + // unavailable. Keep it retryable instead of hiding recommendations for + // the full cache window. + if (result.isNotEmpty) { + _cache = result; + _cachedAt = DateTime.now(); + } return result; } finally { if (identical(_inFlight, request)) _inFlight = null; @@ -59,6 +64,19 @@ class CashuMintRecommendations { Future> _load({ required Set relays, required Duration timeout, + }) async { + var result = await _loadOnce(relays: relays, timeout: timeout); + if (result.isEmpty) { + // A browser's first request can expire while relay connections and event + // verification warm up. Retry once on those now-established connections. + result = await _loadOnce(relays: relays, timeout: timeout); + } + return result; + } + + Future> _loadOnce({ + required Set relays, + required Duration timeout, }) async { final responses = await Future.wait([ _requests diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart b/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart index 3a8ba0201..c70d0e7f3 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart @@ -63,16 +63,23 @@ class Nwc { /// Connects to a given nostr+walletconnect:// uri, /// checking for 13194 event info, - /// and optionally doing a `get_info` request (default false). + /// and optionally doing a `get_info` request (default false). When + /// [requireGetInfoResponse] is true, missing authorization fails connection. /// It subscribes for notifications Future connect( String uri, { bool doGetInfoMethod = false, + bool requireGetInfoResponse = false, bool useETagForEachRequest = false, bool ignoreCapabilitiesCheck = false, Function(String?)? onError, Duration? timeout, }) async { + if (requireGetInfoResponse && !doGetInfoMethod) { + throw ArgumentError( + 'requireGetInfoResponse requires doGetInfoMethod', + ); + } var parsedUri = NostrWalletConnectUri.parseConnectionUri(uri); var relays = parsedUri.relays.map((r) => Uri.decodeFull(r)).toList(); var filter = Filter( @@ -123,13 +130,20 @@ class Nwc { await _subscribeToNotificationsAndResponses(connection); - if (doGetInfoMethod && - (ignoreCapabilitiesCheck || - connection.permissions.contains(NwcMethod.GET_INFO.name))) { + if (doGetInfoMethod) { try { - await getInfo(connection, timeout: timeout); + if (ignoreCapabilitiesCheck || + connection.permissions.contains(NwcMethod.GET_INFO.name)) { + await getInfo(connection, timeout: timeout); + } else if (requireGetInfoResponse) { + throw StateError('Wallet does not advertise get_info'); + } } catch (e) { onError?.call("timeout get_info"); + if (requireGetInfoResponse) { + await disconnect(connection); + rethrow; + } } } Logger.log.i(() => "NWC ${connection.uri} connected"); @@ -137,6 +151,9 @@ class Nwc { completer.complete(connection); } else { onError?.call("not found"); + if (requireGetInfoResponse) { + throw StateError('NWC info event not found'); + } completer.complete( NwcConnection(parsedUri, eventSignerFactory: _eventSignerFactory), ); diff --git a/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart b/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart index dff47df3b..f052f8fda 100644 --- a/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart +++ b/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart @@ -496,6 +496,36 @@ class Wallets { return balances; } + /// Re-establishes access to a wallet's remote service. + /// + /// Providers use [WalletProvider.initialize] for their connectivity check. + /// Any refreshed wallet metadata is persisted without resetting live streams. + Future reconnectWallet(String walletId) async { + await _initializationFuture; + final wallet = await _getWalletForOperation(walletId); + final provider = _providers[wallet.type]; + if (provider == null) { + throw StateError('No provider registered for wallet type ${wallet.type}'); + } + + final updatedWallet = await provider.initialize(wallet); + if (updatedWallet == null) return wallet; + + await _repository.storeWallet(updatedWallet); + final wallets = _wallets.toList(); + final index = wallets.indexWhere((item) => item.id == walletId); + if (index >= 0) { + wallets[index] = updatedWallet; + } else { + wallets.add(updatedWallet); + } + _wallets + ..clear() + ..addAll(wallets); + _safeAddWallets(wallets); + return updatedWallet; + } + Stream> getRecentTransactionsStream(String walletId) { _initRecentTransactionStream(walletId); return _walletRecentTransactionStreams[walletId]!.stream; diff --git a/packages/ndk/test/entities/nwc_wallet_test.dart b/packages/ndk/test/entities/nwc_wallet_test.dart index 44935ca3f..2cfbb6fc3 100644 --- a/packages/ndk/test/entities/nwc_wallet_test.dart +++ b/packages/ndk/test/entities/nwc_wallet_test.dart @@ -5,6 +5,25 @@ import 'package:test/test.dart'; void main() { group('NwcWallet', () { + test('restores authenticated-response requirement from metadata', () { + final wallet = NwcWallet.fromStorage( + id: 'strict-nwc', + name: 'Coinos', + supportedUnits: {'sat'}, + metadata: const { + 'nwcUrl': + 'nostr+walletconnect://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?relay=wss%3A%2F%2Frelay.example.com&secret=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + NwcWallet.kRequireAuthenticatedResponseMetadataKey: true, + }, + ); + + expect(wallet.requireAuthenticatedResponse, isTrue); + expect( + wallet.toMetadata()[NwcWallet.kRequireAuthenticatedResponseMetadataKey], + isTrue, + ); + }); + test('canSend and canReceive use cached permissions from storage', () { final wallet = NwcWallet.fromStorage( id: 'w1', diff --git a/packages/ndk/test/usecases/lnurl/lnurl_test.dart b/packages/ndk/test/usecases/lnurl/lnurl_test.dart index d301d6b09..2f539ea2e 100644 --- a/packages/ndk/test/usecases/lnurl/lnurl_test.dart +++ b/packages/ndk/test/usecases/lnurl/lnurl_test.dart @@ -7,6 +7,7 @@ import 'package:ndk/data_layer/data_sources/http_request.dart'; import 'package:ndk/data_layer/repositories/lnurl_http_impl.dart'; import 'package:ndk/data_layer/repositories/wallets/mem_wallets_repo.dart'; import 'package:ndk/domain_layer/entities/wallet/providers/lnurl/lnurl_wallet_provider.dart'; +import 'package:ndk/domain_layer/entities/wallet/providers/lnurl/lnurl_wallet.dart'; import 'package:ndk/domain_layer/entities/wallet/wallet_type.dart'; import 'package:ndk/domain_layer/usecases/lnurl/lnurl.dart'; import 'package:ndk/domain_layer/usecases/wallets/wallets.dart'; @@ -93,6 +94,43 @@ void main() { expect(await wallets.getWallets(), isEmpty); }); + test('reconnect checks HTTP endpoint even with cached metadata', () async { + final client = MockClient(); + final transport = LnurlTransportHttpImpl(HttpRequestDS(client)); + final lnurl = Lnurl(transport: transport); + final repository = MemWalletsRepo(); + const identifier = 'name@domain.com'; + final link = Lnurl.getLud16LinkFromLud16(identifier)!; + final wallet = LnurlWallet( + id: 'lnurl-wallet', + name: identifier, + supportedUnits: const {'sat'}, + identifier: identifier, + lnurlPayUrl: link, + minSendable: 1000, + maxSendable: 100000, + metadataFetchedAt: DateTime.now().millisecondsSinceEpoch, + ); + await repository.storeWallet(wallet); + final wallets = Wallets( + providers: [LnurlWalletProvider(lnurl)], + repository: repository, + ); + addTearDown(wallets.dispose); + await wallets.getWallets(); + + when(client.get(Uri.parse(link), headers: {'Accept': 'application/json'})) + .thenAnswer((_) async => http.Response('unavailable', 503)); + + await expectLater( + wallets.reconnectWallet(wallet.id), + throwsException, + ); + verify( + client.get(Uri.parse(link), headers: {'Accept': 'application/json'}), + ).called(1); + }); + test('getAmountFromBolt11 returns correct amount for valid input', () { final amount = Lnurl.getAmountFromBolt11( 'lnbc15u1p3xnhl2pp5jptserfk3zk4qy42tlucycrfwxhydvlemu9pqr93tuzlv9cc7g3sdqsvfhkcap3xyhx7un8cqzpgxqzjcsp5f8c52y2stc300gl6s4xswtjpc37hrnnr3c9wvtgjfuvqmpm35evq9qyyssqy4lgd8tj637qcjp05rdpxxykjenthxftej7a2zzmwrmrl70fyj9hvj0rewhzj7jfyuwkwcg9g2jpwtk3wkjtwnkdks84hsnu8xps5vsq4gj5hs', diff --git a/packages/ndk_flutter/README.md b/packages/ndk_flutter/README.md index 118cca0e0..b2b5b7fae 100644 --- a/packages/ndk_flutter/README.md +++ b/packages/ndk_flutter/README.md @@ -63,3 +63,43 @@ By default, the logged-in user is used for user widgets; you can override it by ## Need more Widgets Open an Issue + + +## Shared wallet input UI + +`NWallets` includes the wallet chooser, paste/manual input, Cashu discovery, +LNbits setup, connection status/retry screens, and Alby Cloud/Coinos connection +presets. The presets reuse `albyGoConnectConfig` for the host app name and +callback URL. Register that callback scheme in your app and forward incoming +URLs to `NWalletsState.onProtocolUrlReceived`; on mobile resume without a +callback, call `resumePendingWalletAuth`. Legacy untagged providers are +revalidated every five seconds while their connection screen remains open. + +Only camera decoding is supplied by the host, so ndk_flutter does not depend on +a camera plugin, WebRTC, or a native QR decoder: + +```dart +NWallets( + ndkFlutter: ndkFlutter, + albyGoConnectConfig: const AlbyGoConnectConfig( + appName: 'My app', + appIconUrl: 'https://example.com/icon.png', + callback: 'myapp://nwc', + ), + walletQrScannerBuilder: (context, onScan, onError) => + MyQrCamera(onScan: onScan, onError: onError), +) +``` + +`MyQrCamera` is your camera widget. Report decoded text through `onScan`, +report failures through `onError`, and release camera resources on disposal. +The shared UI removes the camera during nested input dialogs and connection +status, then recreates it when scanning resumes. The same builder is used for +nested NWC and LNbits QR input. Pass null on platforms without camera support; +paste and wallet setup still work. + +No provider list is needed. Set `nwcConnectionOptions` to replace the defaults, +or pass an empty list to disable web presets. `defaultNwcConnectionOptions` +is also available when extending the list. Existing `walletInputScanner`, +`nwcUriScanner` and `bolt12InputScanner` overrides remain supported. +`showWalletInputDialog` exposes the shared UI separately from `NWallets`. diff --git a/packages/ndk_flutter/android/src/main/AndroidManifest.xml b/packages/ndk_flutter/android/src/main/AndroidManifest.xml index 83a54f851..422c2fb52 100644 --- a/packages/ndk_flutter/android/src/main/AndroidManifest.xml +++ b/packages/ndk_flutter/android/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + diff --git a/packages/ndk_flutter/assets/images/albyhub.svg b/packages/ndk_flutter/assets/images/albyhub.svg index 06e23c483..996c27917 100644 --- a/packages/ndk_flutter/assets/images/albyhub.svg +++ b/packages/ndk_flutter/assets/images/albyhub.svg @@ -1,4 +1,4 @@ - + diff --git a/packages/ndk_flutter/lib/l10n/app_de.arb b/packages/ndk_flutter/lib/l10n/app_de.arb index 02cd201e0..6098afecb 100644 --- a/packages/ndk_flutter/lib/l10n/app_de.arb +++ b/packages/ndk_flutter/lib/l10n/app_de.arb @@ -39,6 +39,7 @@ "walletConnectionConnected": "{walletName} verbunden", "walletConnectionFailed": "{walletName} konnte nicht verbunden werden", "retry": "Erneut versuchen", + "walletUnreachable": "Wallet nicht erreichbar", "chooseAnotherWallet": "Andere Wallet auswählen", "chooseWalletAppDescription": "Eine NWC-Verbindung in einer installierten Wallet genehmigen", "walletInput": "Wallet-Adresse oder Verbindung", @@ -468,6 +469,7 @@ "connectNwcTitle": "NWC verbinden", "chooseNwcMethod": "Verbindungsmethode wählen", "albyGoOption": "Alby Go", + "albyGoQrScanInstructions": "Tippe in Alby Go auf „Senden“ und scanne dann diesen QR-Code.", "manualOption": "Manuell", "faucetOption": "Faucet", "invalidNwcQrCode": "Ungültiger NWC QR-Code", diff --git a/packages/ndk_flutter/lib/l10n/app_en.arb b/packages/ndk_flutter/lib/l10n/app_en.arb index 6c009b01e..594ed988f 100644 --- a/packages/ndk_flutter/lib/l10n/app_en.arb +++ b/packages/ndk_flutter/lib/l10n/app_en.arb @@ -1341,6 +1341,10 @@ "placeholders": {"walletName": {"type": "String"}} }, "retry": "Retry", + "walletUnreachable": "Wallet unreachable", + "@walletUnreachable": { + "description": "Status shown when a wallet's remote service cannot be reached" + }, "chooseAnotherWallet": "Choose another wallet", "@chooseWalletApp": { "description": "Button for opening the standard NWC wallet chooser" @@ -1438,6 +1442,10 @@ "@albyGoOption": { "description": "Label for Alby Go option" }, + "albyGoQrScanInstructions": "In Alby Go, tap Send, then scan this QR code.", + "@albyGoQrScanInstructions": { + "description": "Instructions shown beside the Alby Go wallet authorization QR code" + }, "manualOption": "Manual", "@manualOption": { "description": "Label for manual connection option" diff --git a/packages/ndk_flutter/lib/l10n/app_es.arb b/packages/ndk_flutter/lib/l10n/app_es.arb index d2e4e5398..d117d5d85 100644 --- a/packages/ndk_flutter/lib/l10n/app_es.arb +++ b/packages/ndk_flutter/lib/l10n/app_es.arb @@ -39,6 +39,7 @@ "walletConnectionConnected": "{walletName} conectada", "walletConnectionFailed": "No se pudo conectar {walletName}", "retry": "Reintentar", + "walletUnreachable": "Cartera inaccesible", "chooseAnotherWallet": "Elegir otra cartera", "chooseWalletAppDescription": "Aprueba una conexión NWC en una cartera instalada", "walletInput": "Dirección o conexión de cartera", @@ -410,6 +411,7 @@ "connectNwcTitle": "Conectar NWC", "chooseNwcMethod": "Elegir método de conexión", "albyGoOption": "Alby Go", + "albyGoQrScanInstructions": "En Alby Go, toca «Enviar» y luego escanea este código QR.", "manualOption": "Manual", "faucetOption": "Faucet", "invalidNwcQrCode": "Código QR NWC inválido", diff --git a/packages/ndk_flutter/lib/l10n/app_fi.arb b/packages/ndk_flutter/lib/l10n/app_fi.arb index b36498801..d890d8ed1 100644 --- a/packages/ndk_flutter/lib/l10n/app_fi.arb +++ b/packages/ndk_flutter/lib/l10n/app_fi.arb @@ -39,6 +39,7 @@ "walletConnectionConnected": "{walletName} yhdistetty", "walletConnectionFailed": "Lompakkoon {walletName} ei voitu yhdistää", "retry": "Yritä uudelleen", + "walletUnreachable": "Lompakkoa ei tavoiteta", "chooseAnotherWallet": "Valitse toinen lompakko", "chooseWalletAppDescription": "Hyväksy NWC-yhteys asennetussa lompakossa", "walletInput": "Lompakon osoite tai yhteys", @@ -468,6 +469,7 @@ "connectNwcTitle": "Yhdistä NWC", "chooseNwcMethod": "Valitse yhteystapa", "albyGoOption": "Alby Go", + "albyGoQrScanInstructions": "Napauta Alby Go -sovelluksessa Lähetä ja skannaa sitten tämä QR-koodi.", "manualOption": "Manuaalinen", "faucetOption": "Hana", "invalidNwcQrCode": "Virheellinen NWC QR-koodi", diff --git a/packages/ndk_flutter/lib/l10n/app_fr.arb b/packages/ndk_flutter/lib/l10n/app_fr.arb index b7867ae3c..dbea60d71 100644 --- a/packages/ndk_flutter/lib/l10n/app_fr.arb +++ b/packages/ndk_flutter/lib/l10n/app_fr.arb @@ -39,6 +39,7 @@ "walletConnectionConnected": "{walletName} connecté", "walletConnectionFailed": "Impossible de connecter {walletName}", "retry": "Réessayer", + "walletUnreachable": "Portefeuille inaccessible", "chooseAnotherWallet": "Choisir un autre portefeuille", "chooseWalletAppDescription": "Approuvez une connexion NWC dans un portefeuille installé", "walletInput": "Adresse ou connexion du portefeuille", @@ -410,6 +411,7 @@ "connectNwcTitle": "Connecter NWC", "chooseNwcMethod": "Choisir la méthode de connexion", "albyGoOption": "Alby Go", + "albyGoQrScanInstructions": "Dans Alby Go, appuyez sur « Envoyer », puis scannez ce code QR.", "manualOption": "Manuel", "faucetOption": "Faucet", "invalidNwcQrCode": "Code QR NWC invalide", diff --git a/packages/ndk_flutter/lib/l10n/app_it.arb b/packages/ndk_flutter/lib/l10n/app_it.arb index ac74eaa8f..855a61248 100644 --- a/packages/ndk_flutter/lib/l10n/app_it.arb +++ b/packages/ndk_flutter/lib/l10n/app_it.arb @@ -39,6 +39,7 @@ "walletConnectionConnected": "{walletName} connesso", "walletConnectionFailed": "Impossibile connettere {walletName}", "retry": "Riprova", + "walletUnreachable": "Portafoglio non raggiungibile", "chooseAnotherWallet": "Scegli un altro portafoglio", "chooseWalletAppDescription": "Approva una connessione NWC in un portafoglio installato", "walletInput": "Indirizzo o connessione del portafoglio", @@ -468,6 +469,7 @@ "connectNwcTitle": "Connetti NWC", "chooseNwcMethod": "Scegli il metodo di connessione", "albyGoOption": "Alby Go", + "albyGoQrScanInstructions": "In Alby Go, tocca “Invia”, quindi scansiona questo codice QR.", "manualOption": "Manuale", "faucetOption": "Faucet", "invalidNwcQrCode": "Codice QR NWC non valido", diff --git a/packages/ndk_flutter/lib/l10n/app_ja.arb b/packages/ndk_flutter/lib/l10n/app_ja.arb index da3acde59..909a04bde 100644 --- a/packages/ndk_flutter/lib/l10n/app_ja.arb +++ b/packages/ndk_flutter/lib/l10n/app_ja.arb @@ -39,6 +39,7 @@ "walletConnectionConnected": "{walletName}に接続しました", "walletConnectionFailed": "{walletName}に接続できませんでした", "retry": "再試行", + "walletUnreachable": "ウォレットに接続できません", "chooseAnotherWallet": "別のウォレットを選択", "chooseWalletAppDescription": "インストール済みウォレットでNWC接続を承認します", "walletInput": "ウォレットアドレスまたは接続情報", @@ -410,6 +411,7 @@ "connectNwcTitle": "NWCを接続", "chooseNwcMethod": "接続方法を選択", "albyGoOption": "Alby Go", + "albyGoQrScanInstructions": "Alby Goで「送信」をタップして、このQRコードをスキャンしてください。", "manualOption": "手動", "faucetOption": "Faucet", "invalidNwcQrCode": "無効なNWC QRコード", diff --git a/packages/ndk_flutter/lib/l10n/app_localizations.dart b/packages/ndk_flutter/lib/l10n/app_localizations.dart index f1a7e0510..c51d537b4 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations.dart @@ -2207,6 +2207,12 @@ abstract class AppLocalizations { /// **'Retry'** String get retry; + /// Status shown when a wallet's remote service cannot be reached + /// + /// In en, this message translates to: + /// **'Wallet unreachable'** + String get walletUnreachable; + /// No description provided for @chooseAnotherWallet. /// /// In en, this message translates to: @@ -2375,6 +2381,12 @@ abstract class AppLocalizations { /// **'Alby Go'** String get albyGoOption; + /// Instructions shown beside the Alby Go wallet authorization QR code + /// + /// In en, this message translates to: + /// **'In Alby Go, tap Send, then scan this QR code.'** + String get albyGoQrScanInstructions; + /// Label for manual connection option /// /// In en, this message translates to: diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_de.dart b/packages/ndk_flutter/lib/l10n/app_localizations_de.dart index 419a3a56c..1d13ca92e 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_de.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_de.dart @@ -1092,6 +1092,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get retry => 'Erneut versuchen'; + @override + String get walletUnreachable => 'Wallet nicht erreichbar'; + @override String get chooseAnotherWallet => 'Andere Wallet auswählen'; @@ -1187,6 +1190,10 @@ class AppLocalizationsDe extends AppLocalizations { @override String get albyGoOption => 'Alby Go'; + @override + String get albyGoQrScanInstructions => + 'Tippe in Alby Go auf „Senden“ und scanne dann diesen QR-Code.'; + @override String get manualOption => 'Manuell'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_en.dart b/packages/ndk_flutter/lib/l10n/app_localizations_en.dart index eedec5a25..8f1e2ee77 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_en.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_en.dart @@ -1088,6 +1088,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get retry => 'Retry'; + @override + String get walletUnreachable => 'Wallet unreachable'; + @override String get chooseAnotherWallet => 'Choose another wallet'; @@ -1180,6 +1183,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get albyGoOption => 'Alby Go'; + @override + String get albyGoQrScanInstructions => + 'In Alby Go, tap Send, then scan this QR code.'; + @override String get manualOption => 'Manual'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_es.dart b/packages/ndk_flutter/lib/l10n/app_localizations_es.dart index fe0b76857..c1fb0fc33 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_es.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_es.dart @@ -1094,6 +1094,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get retry => 'Reintentar'; + @override + String get walletUnreachable => 'Cartera inaccesible'; + @override String get chooseAnotherWallet => 'Elegir otra cartera'; @@ -1188,6 +1191,10 @@ class AppLocalizationsEs extends AppLocalizations { @override String get albyGoOption => 'Alby Go'; + @override + String get albyGoQrScanInstructions => + 'En Alby Go, toca «Enviar» y luego escanea este código QR.'; + @override String get manualOption => 'Manual'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart b/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart index 9e6598496..d7dd821c1 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart @@ -1090,6 +1090,9 @@ class AppLocalizationsFi extends AppLocalizations { @override String get retry => 'Yritä uudelleen'; + @override + String get walletUnreachable => 'Lompakkoa ei tavoiteta'; + @override String get chooseAnotherWallet => 'Valitse toinen lompakko'; @@ -1183,6 +1186,10 @@ class AppLocalizationsFi extends AppLocalizations { @override String get albyGoOption => 'Alby Go'; + @override + String get albyGoQrScanInstructions => + 'Napauta Alby Go -sovelluksessa Lähetä ja skannaa sitten tämä QR-koodi.'; + @override String get manualOption => 'Manuaalinen'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart b/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart index da908f02c..b4fb23a00 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart @@ -1093,6 +1093,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get retry => 'Réessayer'; + @override + String get walletUnreachable => 'Portefeuille inaccessible'; + @override String get chooseAnotherWallet => 'Choisir un autre portefeuille'; @@ -1187,6 +1190,10 @@ class AppLocalizationsFr extends AppLocalizations { @override String get albyGoOption => 'Alby Go'; + @override + String get albyGoQrScanInstructions => + 'Dans Alby Go, appuyez sur « Envoyer », puis scannez ce code QR.'; + @override String get manualOption => 'Manuel'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_it.dart b/packages/ndk_flutter/lib/l10n/app_localizations_it.dart index f39561a8f..d83291f3c 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_it.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_it.dart @@ -1095,6 +1095,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get retry => 'Riprova'; + @override + String get walletUnreachable => 'Portafoglio non raggiungibile'; + @override String get chooseAnotherWallet => 'Scegli un altro portafoglio'; @@ -1189,6 +1192,10 @@ class AppLocalizationsIt extends AppLocalizations { @override String get albyGoOption => 'Alby Go'; + @override + String get albyGoQrScanInstructions => + 'In Alby Go, tocca “Invia”, quindi scansiona questo codice QR.'; + @override String get manualOption => 'Manuale'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart b/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart index 474f2513d..266944071 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart @@ -1080,6 +1080,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get retry => '再試行'; + @override + String get walletUnreachable => 'ウォレットに接続できません'; + @override String get chooseAnotherWallet => '別のウォレットを選択'; @@ -1168,6 +1171,10 @@ class AppLocalizationsJa extends AppLocalizations { @override String get albyGoOption => 'Alby Go'; + @override + String get albyGoQrScanInstructions => + 'Alby Goで「送信」をタップして、このQRコードをスキャンしてください。'; + @override String get manualOption => '手動'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart b/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart index 563d6065f..a47b82721 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart @@ -1094,6 +1094,9 @@ class AppLocalizationsPl extends AppLocalizations { @override String get retry => 'Spróbuj ponownie'; + @override + String get walletUnreachable => 'Portfel jest nieosiągalny'; + @override String get chooseAnotherWallet => 'Wybierz inny portfel'; @@ -1188,6 +1191,10 @@ class AppLocalizationsPl extends AppLocalizations { @override String get albyGoOption => 'Alby Go'; + @override + String get albyGoQrScanInstructions => + 'W Alby Go wybierz „Wyślij”, a następnie zeskanuj ten kod QR.'; + @override String get manualOption => 'Ręcznie'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart b/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart index 7a502f1fe..28d73f6d3 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart @@ -1097,6 +1097,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get retry => 'Tentar novamente'; + @override + String get walletUnreachable => 'Carteira inacessível'; + @override String get chooseAnotherWallet => 'Escolher outra carteira'; @@ -1191,6 +1194,10 @@ class AppLocalizationsPt extends AppLocalizations { @override String get albyGoOption => 'Alby Go'; + @override + String get albyGoQrScanInstructions => + 'No Alby Go, toque em «Enviar» e depois leia este código QR.'; + @override String get manualOption => 'Manual'; @@ -2574,6 +2581,9 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get retry => 'Tentar novamente'; + @override + String get walletUnreachable => 'Carteira inacessível'; + @override String get chooseAnotherWallet => 'Escolher outra carteira'; @@ -2668,6 +2678,10 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get albyGoOption => 'Alby Go'; + @override + String get albyGoQrScanInstructions => + 'No Alby Go, toque em “Enviar” e escaneie este código QR.'; + @override String get manualOption => 'Manual'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart b/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart index 920c46ee1..01aebcd3b 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart @@ -1091,6 +1091,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get retry => 'Повторить'; + @override + String get walletUnreachable => 'Кошелёк недоступен'; + @override String get chooseAnotherWallet => 'Выбрать другой кошелёк'; @@ -1186,6 +1189,10 @@ class AppLocalizationsRu extends AppLocalizations { @override String get albyGoOption => 'Alby Go'; + @override + String get albyGoQrScanInstructions => + 'В Alby Go нажмите «Отправить», затем отсканируйте этот QR-код.'; + @override String get manualOption => 'Вручную'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart b/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart index 7fca3c822..b53bd9457 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart @@ -1090,6 +1090,9 @@ class AppLocalizationsSk extends AppLocalizations { @override String get retry => 'Skúsiť znova'; + @override + String get walletUnreachable => 'Peňaženka je nedostupná'; + @override String get chooseAnotherWallet => 'Vybrať inú peňaženku'; @@ -1183,6 +1186,10 @@ class AppLocalizationsSk extends AppLocalizations { @override String get albyGoOption => 'Alby Go'; + @override + String get albyGoQrScanInstructions => + 'V Alby Go ťuknite na „Odoslať“ a potom naskenujte tento QR kód.'; + @override String get manualOption => 'Manuálne'; diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart b/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart index 83bd9a10e..f935b9c8a 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart @@ -1077,6 +1077,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get retry => '重试'; + @override + String get walletUnreachable => '无法连接钱包'; + @override String get chooseAnotherWallet => '选择其他钱包'; @@ -1164,6 +1167,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get albyGoOption => 'Alby Go'; + @override + String get albyGoQrScanInstructions => '在 Alby Go 中点击“发送”,然后扫描此二维码。'; + @override String get manualOption => '手动'; diff --git a/packages/ndk_flutter/lib/l10n/app_pl.arb b/packages/ndk_flutter/lib/l10n/app_pl.arb index 5c68b958f..da8f69bf8 100644 --- a/packages/ndk_flutter/lib/l10n/app_pl.arb +++ b/packages/ndk_flutter/lib/l10n/app_pl.arb @@ -39,6 +39,7 @@ "walletConnectionConnected": "Połączono z {walletName}", "walletConnectionFailed": "Nie udało się połączyć z {walletName}", "retry": "Spróbuj ponownie", + "walletUnreachable": "Portfel jest nieosiągalny", "chooseAnotherWallet": "Wybierz inny portfel", "chooseWalletAppDescription": "Zatwierdź połączenie NWC w zainstalowanym portfelu", "walletInput": "Adres lub połączenie portfela", @@ -468,6 +469,7 @@ "connectNwcTitle": "Połącz NWC", "chooseNwcMethod": "Wybierz metodę połączenia", "albyGoOption": "Alby Go", + "albyGoQrScanInstructions": "W Alby Go wybierz „Wyślij”, a następnie zeskanuj ten kod QR.", "manualOption": "Ręcznie", "faucetOption": "Kran", "invalidNwcQrCode": "Nieprawidłowy kod QR NWC", diff --git a/packages/ndk_flutter/lib/l10n/app_pt.arb b/packages/ndk_flutter/lib/l10n/app_pt.arb index 8be4ec305..14b8fbe83 100644 --- a/packages/ndk_flutter/lib/l10n/app_pt.arb +++ b/packages/ndk_flutter/lib/l10n/app_pt.arb @@ -39,6 +39,7 @@ "walletConnectionConnected": "{walletName} ligada", "walletConnectionFailed": "Não foi possível ligar a {walletName}", "retry": "Tentar novamente", + "walletUnreachable": "Carteira inacessível", "chooseAnotherWallet": "Escolher outra carteira", "chooseWalletAppDescription": "Aprove uma ligação NWC numa carteira instalada", "walletInput": "Endereço ou ligação da carteira", @@ -410,6 +411,7 @@ "connectNwcTitle": "Ligar NWC", "chooseNwcMethod": "Escolha o método de ligação", "albyGoOption": "Alby Go", + "albyGoQrScanInstructions": "No Alby Go, toque em «Enviar» e depois leia este código QR.", "manualOption": "Manual", "faucetOption": "Faucet", "invalidNwcQrCode": "Código QR NWC inválido", diff --git a/packages/ndk_flutter/lib/l10n/app_pt_BR.arb b/packages/ndk_flutter/lib/l10n/app_pt_BR.arb index 9614061b3..d8bedb9a2 100644 --- a/packages/ndk_flutter/lib/l10n/app_pt_BR.arb +++ b/packages/ndk_flutter/lib/l10n/app_pt_BR.arb @@ -39,6 +39,7 @@ "walletConnectionConnected": "{walletName} conectada", "walletConnectionFailed": "Não foi possível conectar a {walletName}", "retry": "Tentar novamente", + "walletUnreachable": "Carteira inacessível", "chooseAnotherWallet": "Escolher outra carteira", "chooseWalletAppDescription": "Aprove uma conexão NWC em uma carteira instalada", "walletInput": "Endereço ou conexão da carteira", @@ -410,6 +411,7 @@ "connectNwcTitle": "Conectar NWC", "chooseNwcMethod": "Escolha o método de conexão", "albyGoOption": "Alby Go", + "albyGoQrScanInstructions": "No Alby Go, toque em “Enviar” e escaneie este código QR.", "manualOption": "Manual", "faucetOption": "Faucet", "invalidNwcQrCode": "QR code NWC inválido", diff --git a/packages/ndk_flutter/lib/l10n/app_ru.arb b/packages/ndk_flutter/lib/l10n/app_ru.arb index af77208a8..206bed561 100644 --- a/packages/ndk_flutter/lib/l10n/app_ru.arb +++ b/packages/ndk_flutter/lib/l10n/app_ru.arb @@ -39,6 +39,7 @@ "walletConnectionConnected": "{walletName} подключён", "walletConnectionFailed": "Не удалось подключить {walletName}", "retry": "Повторить", + "walletUnreachable": "Кошелёк недоступен", "chooseAnotherWallet": "Выбрать другой кошелёк", "chooseWalletAppDescription": "Подтвердите NWC-подключение в установленном кошельке", "walletInput": "Адрес или подключение кошелька", @@ -410,6 +411,7 @@ "connectNwcTitle": "Подключить NWC", "chooseNwcMethod": "Выберите способ подключения", "albyGoOption": "Alby Go", + "albyGoQrScanInstructions": "В Alby Go нажмите «Отправить», затем отсканируйте этот QR-код.", "manualOption": "Вручную", "faucetOption": "Кран", "invalidNwcQrCode": "Неверный QR-код NWC", diff --git a/packages/ndk_flutter/lib/l10n/app_sk.arb b/packages/ndk_flutter/lib/l10n/app_sk.arb index 3b0d90b39..d29dd8b3c 100644 --- a/packages/ndk_flutter/lib/l10n/app_sk.arb +++ b/packages/ndk_flutter/lib/l10n/app_sk.arb @@ -39,6 +39,7 @@ "walletConnectionConnected": "{walletName} pripojená", "walletConnectionFailed": "Nepodarilo sa pripojiť {walletName}", "retry": "Skúsiť znova", + "walletUnreachable": "Peňaženka je nedostupná", "chooseAnotherWallet": "Vybrať inú peňaženku", "chooseWalletAppDescription": "Schváľte pripojenie NWC v nainštalovanej peňaženke", "walletInput": "Adresa alebo pripojenie peňaženky", @@ -468,6 +469,7 @@ "connectNwcTitle": "Pripojiť NWC", "chooseNwcMethod": "Vyberte spôsob pripojenia", "albyGoOption": "Alby Go", + "albyGoQrScanInstructions": "V Alby Go ťuknite na „Odoslať“ a potom naskenujte tento QR kód.", "manualOption": "Manuálne", "faucetOption": "Faucet", "invalidNwcQrCode": "Neplatný NWC QR kód", diff --git a/packages/ndk_flutter/lib/l10n/app_zh.arb b/packages/ndk_flutter/lib/l10n/app_zh.arb index b39c85698..c54ad6ae8 100644 --- a/packages/ndk_flutter/lib/l10n/app_zh.arb +++ b/packages/ndk_flutter/lib/l10n/app_zh.arb @@ -39,6 +39,7 @@ "walletConnectionConnected": "已连接 {walletName}", "walletConnectionFailed": "无法连接 {walletName}", "retry": "重试", + "walletUnreachable": "无法连接钱包", "chooseAnotherWallet": "选择其他钱包", "chooseWalletAppDescription": "在已安装的钱包中批准 NWC 连接", "walletInput": "钱包地址或连接信息", @@ -410,6 +411,7 @@ "connectNwcTitle": "连接 NWC", "chooseNwcMethod": "选择连接方式", "albyGoOption": "Alby Go", + "albyGoQrScanInstructions": "在 Alby Go 中点击“发送”,然后扫描此二维码。", "manualOption": "手动", "faucetOption": "水龙头", "invalidNwcQrCode": "无效的NWC二维码", diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_add_wallet_dialogs.dart b/packages/ndk_flutter/lib/widgets/wallets/n_add_wallet_dialogs.dart index 5fcee48de..c1a79c02d 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_add_wallet_dialogs.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_add_wallet_dialogs.dart @@ -11,8 +11,10 @@ import 'package:ndk/entities.dart'; import 'package:ndk/domain_layer/usecases/nwc/consts/nwc_kind.dart'; import 'package:ndk/ndk.dart'; import 'package:ndk/shared/nips/nip01/bip340.dart'; +import 'package:ndk/shared/nips/nip01/helpers.dart'; import 'package:ndk/shared/nips/nip01/key_pair.dart'; import 'package:ndk_flutter/ndk_flutter.dart'; +import 'package:pretty_qr_code/pretty_qr_code.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../l10n/app_localizations.dart'; @@ -195,6 +197,47 @@ class NwcConnectionOption { }); } +/// Default assisted web wallet connections, using the host app's identity and +/// callback from [config]. Pass an explicit list to override or disable them. +List defaultNwcConnectionOptions({ + AlbyGoConnectConfig config = kDefaultAlbyGoConnectConfig, +}) => [ + NwcConnectionOption( + id: 'alby-cloud', + label: 'Alby Cloud', + connect: (context, ndkFlutter, coordinator) => + coordinator.connectWebWalletAuth( + context, + authorizationEndpoint: Uri.parse('https://my.albyhub.com/apps/new'), + appName: config.appName, + discoveryRelay: config.discoveryRelay, + callback: config.callback, + walletName: 'Alby Cloud', + providerId: 'alby', + waitForDiscoveryNdkFlutter: ndkFlutter, + additionalQueryParameters: {'return_to': config.callback}, + ), + ), + NwcConnectionOption( + id: 'coinos', + label: 'Coinos', + connect: (context, ndkFlutter, coordinator) => + coordinator.connectWebWalletAuth( + context, + authorizationEndpoint: Uri.parse('https://coinos.io/apps/new'), + appName: config.appName, + discoveryRelay: 'wss://relay.coinos.io', + callback: config.callback, + walletName: 'Coinos', + providerId: 'coinos', + waitForDiscoveryNdkFlutter: ndkFlutter, + allowUntaggedInfoEvent: true, + walletServicePubkey: + 'ba80990666ef0b6f4ba5059347beb13242921e54669e680064ca755256a1e3a6', + ), + ), +]; + /// Wallet input categories recognized by the unified add-wallet flow. enum WalletInputKind { nwc, bolt12, lightningAddress, cashuMint, lnBits } @@ -239,9 +282,12 @@ WalletInputKind? classifyWalletInput(String input) { } String _normalizeWalletInput(String input) { - final value = input.trim(); + var value = input.trim(); if (value.toLowerCase().startsWith('lightning:')) { - return value.substring('lightning:'.length).trim(); + value = value.substring('lightning:'.length).trim(); + } + if (value.toLowerCase().startsWith('bitcoin?')) { + value = 'bitcoin:${value.substring('bitcoin'.length)}'; } return value; } @@ -251,6 +297,7 @@ Uri buildNwcWebWalletAuthUri({ required Uri authorizationEndpoint, required String appName, required String pubkey, + required String state, Map additionalQueryParameters = const {}, }) { return authorizationEndpoint.replace( @@ -259,6 +306,7 @@ Uri buildNwcWebWalletAuthUri({ ...additionalQueryParameters, 'name': appName, 'pubkey': pubkey, + 'state': state, }, ); } @@ -267,22 +315,77 @@ Uri buildNwcWebWalletAuthUri({ Uri buildNwcWalletAuthUri({ required String appPubkey, required AlbyGoConnectConfig config, + required String state, + String scheme = 'nostr+walletauth', + bool includeReturnTo = true, }) { return Uri( - scheme: 'nostr+walletauth', + scheme: scheme, host: appPubkey, queryParameters: { 'relay': config.discoveryRelay, + 'state': state, 'name': config.appName, 'request_methods': config.requestMethods .map((method) => method.name) .join(' '), 'icon': config.appIconUrl, - 'return_to': config.callback, + if (includeReturnTo) 'return_to': config.callback, + }, + ); +} + +/// Builds an NWC-07 callback URI handled by Primal, Alby Go, and other wallets +/// registered for `nostrnwc://connect`. +Uri buildNwcCallbackUri({ + required AlbyGoConnectConfig config, + String scheme = 'nostrnwc', +}) { + return Uri( + scheme: scheme, + host: config.nostrNwcHost, + queryParameters: { + 'appname': config.appName, + 'appicon': config.appIconUrl, + 'callback': config.callback, }, ); } +/// Generates NWC-08 correlation state with 128 bits of secure randomness. +String generateNwcWalletAuthState() => Helpers.getSecureRandomHex(16); + +/// Validates discovery against client key and any returned correlation state. +/// Missing state remains accepted for wallets implementing earlier drafts. +bool matchesNwcWalletAuthInfoEvent( + Nip01Event event, { + required String appPubkey, + required String state, + String? walletServicePubkey, + bool requireAppPubkeyTag = true, +}) { + final returnedState = event.getFirstTag('state'); + return event.kind == NwcKind.INFO.value && + (!requireAppPubkeyTag || event.pTags.contains(appPubkey.toLowerCase())) && + (returnedState == null || + returnedState.isEmpty || + returnedState == state) && + (walletServicePubkey == null || event.pubKey == walletServicePubkey); +} + +/// Uses wallet-service relay recommendation when NWC-08 info provides one. +String walletAuthConnectionRelay( + Nip01Event event, { + required String fallbackRelay, +}) { + for (final tag in event.tags) { + if (tag.length > 1 && tag[0] == 'relay' && tag[1].trim().isNotEmpty) { + return tag[1]; + } + } + return fallbackRelay; +} + enum AlbyGoConnectMethod { walletAuth, nostrNwcCallback } enum WalletConnectionPhase { @@ -328,10 +431,6 @@ const List _defaultAlbyGoRequestMethods = [ NwcMethod.PAY_INVOICE, NwcMethod.LOOKUP_INVOICE, NwcMethod.LIST_TRANSACTIONS, - NwcMethod.SIGN_MESSAGE, - NwcMethod.MAKE_HOLD_INVOICE, - NwcMethod.CANCEL_HOLD_INVOICE, - NwcMethod.SETTLE_HOLD_INVOICE, ]; /// Configuration for launching the Alby Go NWC connection intent. @@ -345,6 +444,9 @@ class AlbyGoConnectConfig { final List requestMethods; final String walletName; final AlbyGoConnectMethod connectMethod; + final String walletAuthScheme; + final String nostrNwcScheme; + final String androidPackage; /// Host used when [connectMethod] is [AlbyGoConnectMethod.nostrNwcCallback]. final String nostrNwcHost; @@ -357,6 +459,9 @@ class AlbyGoConnectConfig { this.requestMethods = _defaultAlbyGoRequestMethods, this.walletName = 'Alby Go', this.connectMethod = AlbyGoConnectMethod.walletAuth, + this.walletAuthScheme = 'nostr+walletauth+alby', + this.nostrNwcScheme = 'nostrnwc+alby', + this.androidPackage = 'com.getalby.mobile', this.nostrNwcHost = 'connect', }); } @@ -374,6 +479,7 @@ class NwcWalletAuthCoordinator { String? _lastConnectedWalletId; bool _isCompletingPendingSession = false; Future Function()? _retryLaunch; + Future Function()? _closeWalletAuthSubscription; final ValueNotifier connectionState = ValueNotifier( const WalletConnectionState.idle(), ); @@ -381,10 +487,15 @@ class NwcWalletAuthCoordinator { bool get hasPendingSession => _pendingSession != null; void cancelPendingConnection() { + final closeSubscription = _closeWalletAuthSubscription; + _closeWalletAuthSubscription = null; _pendingSession = null; _pendingCallbackSession = null; _retryLaunch = null; connectionState.value = const WalletConnectionState.idle(); + if (closeSubscription != null) { + unawaited(closeSubscription().catchError((_) {})); + } } /// Clears stale terminal UI state before starting a new add-wallet flow. @@ -476,10 +587,11 @@ class NwcWalletAuthCoordinator { try { if (!kIsWeb && Platform.isAndroid) { - await AndroidIntent( + final intent = AndroidIntent( action: 'action_view', data: launchUri.toString(), - ).launch(); + ); + await intent.launch(); } else { final launched = await launchUrl( launchUri, @@ -501,12 +613,17 @@ class NwcWalletAuthCoordinator { } } - /// Opens standard wallet-auth URI in any compatible installed wallet. + /// Opens an NWC-07 URI using normal platform intent resolution. Future connectInstalledWallet( BuildContext context, { required AlbyGoConnectConfig config, }) { - return connectWalletAuth(context, config: config, walletName: 'NWC'); + return connectWithUri( + context, + launchUri: buildNwcCallbackUri(config: config), + callback: config.callback, + walletName: 'NWC', + ); } /// Opens standard `nostr+walletauth://` URI in a compatible wallet. @@ -515,8 +632,14 @@ class NwcWalletAuthCoordinator { required AlbyGoConnectConfig config, required String walletName, String? providerId, + String uriScheme = 'nostr+walletauth', + String? androidPackage, + NdkFlutter? qrFallbackNdkFlutter, + bool showAlbyGoQrInstructions = false, }) async { - if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) return; + final canLaunchWalletApp = + !kIsWeb && (Platform.isAndroid || Platform.isIOS); + if (!canLaunchWalletApp && qrFallbackNdkFlutter == null) return; final appKey = Bip340.generatePrivateKey(); _retryLaunch = () => connectWalletAuth( @@ -524,10 +647,24 @@ class NwcWalletAuthCoordinator { config: config, walletName: walletName, providerId: providerId, + uriScheme: uriScheme, + androidPackage: androidPackage, + qrFallbackNdkFlutter: qrFallbackNdkFlutter, + showAlbyGoQrInstructions: showAlbyGoQrInstructions, ); + final state = generateNwcWalletAuthState(); final launchUri = buildNwcWalletAuthUri( appPubkey: appKey.publicKey, config: config, + state: state, + scheme: uriScheme, + ); + final qrUri = buildNwcWalletAuthUri( + appPubkey: appKey.publicKey, + config: config, + state: state, + scheme: uriScheme, + includeReturnTo: false, ); _pendingSession = _PendingNwcWalletAuthSession( @@ -536,16 +673,40 @@ class NwcWalletAuthCoordinator { returnTo: config.callback, walletName: walletName, providerId: providerId, + state: state, + allowUntaggedInfoEvent: false, ); _pendingCallbackSession = null; _markAwaiting(walletName); + if (!canLaunchWalletApp) { + if (!context.mounted) return; + await _showWalletAuthDiscoveryDialog( + context, + authorizationUri: qrUri, + walletName: walletName, + ndkFlutter: qrFallbackNdkFlutter!, + showAlbyGoQrInstructions: showAlbyGoQrInstructions, + ); + return; + } + try { if (Platform.isAndroid) { - await AndroidIntent( + final intent = AndroidIntent( action: 'action_view', data: launchUri.toString(), - ).launch(); + package: androidPackage, + ); + if (androidPackage != null) { + if (await intent.canResolveActivity() != true) { + throw StateError('Wallet app is not installed'); + } + await intent.launch(); + } else { + final l10n = AppLocalizations.of(context)!; + await intent.launchChooser(l10n.chooseWalletApp); + } } else { final launched = await launchUrl( launchUri, @@ -553,7 +714,27 @@ class NwcWalletAuthCoordinator { ); if (!launched) throw StateError('Could not launch wallet app'); } + if (qrFallbackNdkFlutter != null && + hasPendingSession && + context.mounted) { + await _showWalletAuthDiscoveryDialog( + context, + walletName: walletName, + ndkFlutter: qrFallbackNdkFlutter, + showAlbyGoQrInstructions: showAlbyGoQrInstructions, + ); + } } catch (error) { + if (qrFallbackNdkFlutter != null && context.mounted) { + await _showWalletAuthDiscoveryDialog( + context, + authorizationUri: qrUri, + walletName: walletName, + ndkFlutter: qrFallbackNdkFlutter, + showAlbyGoQrInstructions: showAlbyGoQrInstructions, + ); + return; + } _pendingSession = null; _markFailed(walletName, error); if (!context.mounted) return; @@ -567,6 +748,26 @@ class NwcWalletAuthCoordinator { } } + Future _showWalletAuthDiscoveryDialog( + BuildContext context, { + Uri? authorizationUri, + required String walletName, + required NdkFlutter ndkFlutter, + bool showAlbyGoQrInstructions = false, + }) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (_) => _NwcWalletAuthDiscoveryDialog( + authorizationUri: authorizationUri, + walletName: walletName, + coordinator: this, + ndkFlutter: ndkFlutter, + showAlbyGoQrInstructions: showAlbyGoQrInstructions, + ), + ); + } + /// Starts client-key NWC authorization through a web wallet. /// /// [appName] is sent as `name`; generated public key is sent as `pubkey`. @@ -581,9 +782,17 @@ class NwcWalletAuthCoordinator { required String walletName, String? providerId, String? walletServicePubkey, + NdkFlutter? waitForDiscoveryNdkFlutter, + bool allowUntaggedInfoEvent = false, Map additionalQueryParameters = const {}, }) async { + if (allowUntaggedInfoEvent && walletServicePubkey == null) { + throw ArgumentError( + 'walletServicePubkey is required for untagged info-event discovery', + ); + } final appKey = Bip340.generatePrivateKey(); + final state = generateNwcWalletAuthState(); _retryLaunch = () => connectWebWalletAuth( context, authorizationEndpoint: authorizationEndpoint, @@ -593,12 +802,15 @@ class NwcWalletAuthCoordinator { walletName: walletName, providerId: providerId, walletServicePubkey: walletServicePubkey, + waitForDiscoveryNdkFlutter: waitForDiscoveryNdkFlutter, + allowUntaggedInfoEvent: allowUntaggedInfoEvent, additionalQueryParameters: additionalQueryParameters, ); final launchUri = buildNwcWebWalletAuthUri( authorizationEndpoint: authorizationEndpoint, appName: appName, pubkey: appKey.publicKey, + state: state, additionalQueryParameters: additionalQueryParameters, ); @@ -609,6 +821,8 @@ class NwcWalletAuthCoordinator { walletName: walletName, walletServicePubkey: walletServicePubkey, providerId: providerId, + state: state, + allowUntaggedInfoEvent: allowUntaggedInfoEvent, ); _pendingCallbackSession = null; _markAwaiting(walletName); @@ -619,6 +833,15 @@ class NwcWalletAuthCoordinator { mode: LaunchMode.externalApplication, ); if (!launched) throw StateError('Could not launch wallet provider'); + if (waitForDiscoveryNdkFlutter != null && + hasPendingSession && + context.mounted) { + await _showWalletAuthDiscoveryDialog( + context, + walletName: walletName, + ndkFlutter: waitForDiscoveryNdkFlutter, + ); + } } catch (error) { _pendingSession = null; _markFailed(walletName, error); @@ -638,21 +861,25 @@ class NwcWalletAuthCoordinator { NdkFlutter ndkFlutter, { AlbyGoConnectConfig config = kDefaultAlbyGoConnectConfig, }) async { - if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) return; - if (config.connectMethod == AlbyGoConnectMethod.walletAuth) { return connectWalletAuth( context, config: config, walletName: config.walletName, providerId: 'alby', + uriScheme: config.walletAuthScheme, + androidPackage: config.androidPackage, + qrFallbackNdkFlutter: ndkFlutter, + showAlbyGoQrInstructions: true, ); } + if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) return; + _retryLaunch = () => connectAlbyGo(context, ndkFlutter, config: config); final launchUri = Uri( - scheme: 'nostrnwc', + scheme: config.nostrNwcScheme, host: config.nostrNwcHost, queryParameters: { 'appname': config.appName, @@ -673,6 +900,7 @@ class NwcWalletAuthCoordinator { final intent = AndroidIntent( action: 'action_view', data: launchUri.toString(), + package: config.androidPackage, ); await intent.launch(); } else { @@ -710,10 +938,18 @@ class NwcWalletAuthCoordinator { final returnedUri = Uri.tryParse(url); final pendingWalletAuth = _pendingSession; - final returnedRelay = returnedUri?.queryParameters['relay']; - final returnedWalletPubkey = returnedUri?.queryParameters['pubkey']; + final returnedRelay = + returnedUri?.queryParameters['relay_url'] ?? + returnedUri?.queryParameters['relay']; + final returnedWalletPubkey = + returnedUri?.queryParameters['wallet_pubkey'] ?? + returnedUri?.queryParameters['pubkey']; + final returnedState = returnedUri?.queryParameters['state']; if (pendingWalletAuth != null && _matchesReturnTo(url, pendingWalletAuth.returnTo) && + (returnedState == null || + returnedState.isEmpty || + returnedState == pendingWalletAuth.state) && returnedRelay != null && returnedRelay.isNotEmpty && returnedWalletPubkey != null && @@ -735,6 +971,9 @@ class NwcWalletAuthCoordinator { providerId: pendingWalletAuth.providerId, ); _pendingSession = null; + final closeSubscription = _closeWalletAuthSubscription; + _closeWalletAuthSubscription = null; + await closeSubscription?.call(); connectionState.value = WalletConnectionState.connected( pendingWalletAuth.walletName, ); @@ -779,6 +1018,10 @@ class NwcWalletAuthCoordinator { providerId: pendingCallbackSession?.providerId ?? _pendingSession?.providerId, ); + _pendingSession = null; + final closeSubscription = _closeWalletAuthSubscription; + _closeWalletAuthSubscription = null; + await closeSubscription?.call(); connectionState.value = WalletConnectionState.connected( pendingCallbackSession?.walletName ?? _pendingSession?.walletName ?? @@ -830,8 +1073,10 @@ class NwcWalletAuthCoordinator { /// a callback URL. Future completePendingWalletAuth( BuildContext context, - NdkFlutter ndkFlutter, - ) async { + NdkFlutter ndkFlutter, { + Duration? timeout = const Duration(seconds: 15), + bool showMessages = true, + }) async { final pendingSession = _pendingSession; if (pendingSession == null || _isCompletingPendingSession) return false; @@ -846,31 +1091,158 @@ class NwcWalletAuthCoordinator { _pendingCallbackSession = null; - if (context.mounted) { + if (showMessages && context.mounted) { scaffoldMessenger!.showSnackBar( SnackBar(content: Text(l10n!.fetchingWalletConnectionInfo)), ); } + Future Function()? closeSubscription; try { - final stream = ndkFlutter.ndk.requests - .query( - filter: Filter( - kinds: [NwcKind.INFO.value], - authors: pendingSession.walletServicePubkey == null - ? null - : [pendingSession.walletServicePubkey!], - pTags: pendingSession.walletServicePubkey == null - ? [pendingSession.appKey.publicKey] - : null, - limit: 1, - ), - explicitRelays: {pendingSession.discoveryRelay}, - ) - .stream - .timeout(const Duration(seconds: 15)); + final requests = ndkFlutter.ndk.requests; + final subscription = requests.subscription( + filter: Filter( + kinds: [NwcKind.INFO.value], + authors: pendingSession.walletServicePubkey == null + ? null + : [pendingSession.walletServicePubkey!], + pTags: pendingSession.allowUntaggedInfoEvent + ? null + : [pendingSession.appKey.publicKey], + ), + explicitRelays: {pendingSession.discoveryRelay}, + ); + var subscriptionClosed = false; + Future closeCurrentSubscription() async { + if (subscriptionClosed) return; + subscriptionClosed = true; + await requests.closeSubscription( + subscription.requestId, + debugLabel: 'NWC wallet authorization', + ); + } + + closeSubscription = closeCurrentSubscription; + _closeWalletAuthSubscription = closeCurrentSubscription; + final matchingEvents = subscription.stream.where( + (event) => matchesNwcWalletAuthInfoEvent( + event, + appPubkey: pendingSession.appKey.publicKey, + state: pendingSession.state, + walletServicePubkey: pendingSession.walletServicePubkey, + requireAppPubkeyTag: !pendingSession.allowUntaggedInfoEvent, + ), + ); + + var walletAddedDuringDiscovery = false; + Future findUsableInfoEvent() async { + if (!pendingSession.allowUntaggedInfoEvent) { + return matchingEvents.first; + } + + final usableEvent = Completer(); + Nip01Event? latestInfoEvent; + var validating = false; + var retryRequested = false; + + Future validateLatestInfoEvent() async { + if (validating) { + retryRequested = true; + return; + } + validating = true; + try { + do { + retryRequested = false; + final event = latestInfoEvent; + if (event == null || usableEvent.isCompleted) return; + if (!identical(_pendingSession, pendingSession)) { + if (!usableEvent.isCompleted) { + usableEvent.completeError( + StateError('Wallet connection cancelled'), + ); + } + return; + } + + final secret = pendingSession.appKey.privateKey; + if (secret == null) { + usableEvent.completeError( + StateError( + 'Generated wallet auth keypair is missing a private key', + ), + ); + return; + } + final relay = walletAuthConnectionRelay( + event, + fallbackRelay: pendingSession.discoveryRelay, + ); + final nwcUri = + 'nostr+walletconnect://${pendingSession.walletServicePubkey}?relay=${Uri.encodeComponent(relay)}&secret=$secret'; + try { + await _addNwcWallet( + ndkFlutter, + nwcUri: nwcUri, + walletName: pendingSession.walletName, + providerId: pendingSession.providerId, + requireAuthenticatedResponse: true, + ); + walletAddedDuringDiscovery = true; + usableEvent.complete(event); + return; + } catch (error) { + // A generic info event proves service availability, not client + // authorization. Retry every five seconds while this discovery + // session remains open. + Logger.log.d( + () => + 'NWC wallet authorization not ready for ${pendingSession.walletName}: $error', + ); + } + } while (retryRequested && !usableEvent.isCompleted); + } finally { + validating = false; + } + } - final Nip01Event foundWalletAuthEvent = await stream.first; + final matchingEventsSubscription = matchingEvents.listen( + (event) { + latestInfoEvent = event; + unawaited(validateLatestInfoEvent()); + }, + onError: (Object error, StackTrace stackTrace) { + if (!usableEvent.isCompleted) { + usableEvent.completeError(error, stackTrace); + } + }, + onDone: () { + if (!usableEvent.isCompleted) { + usableEvent.completeError( + StateError('Wallet info subscription closed'), + ); + } + }, + ); + + final validationTimer = Timer.periodic(const Duration(seconds: 5), (_) { + retryRequested = true; + unawaited(validateLatestInfoEvent()); + }); + + try { + return await usableEvent.future; + } finally { + validationTimer.cancel(); + await matchingEventsSubscription.cancel(); + } + } + + final usableInfoEvent = findUsableInfoEvent(); + final foundWalletAuthEvent = timeout == null + ? await usableInfoEvent + : await usableInfoEvent.timeout(timeout); + if (!identical(_pendingSession, pendingSession)) return false; final appPrivateKey = pendingSession.appKey.privateKey; if (appPrivateKey == null) { @@ -881,15 +1253,22 @@ class NwcWalletAuthCoordinator { final walletServicePubkey = pendingSession.walletServicePubkey ?? foundWalletAuthEvent.pubKey; + final connectionRelay = walletAuthConnectionRelay( + foundWalletAuthEvent, + fallbackRelay: pendingSession.discoveryRelay, + ); final constructedNwcUri = - 'nostr+walletconnect://$walletServicePubkey?relay=${Uri.encodeComponent(pendingSession.discoveryRelay)}&secret=$appPrivateKey'; + 'nostr+walletconnect://$walletServicePubkey?relay=${Uri.encodeComponent(connectionRelay)}&secret=$appPrivateKey'; - await _addNwcWallet( - ndkFlutter, - nwcUri: constructedNwcUri, - walletName: pendingSession.walletName, - providerId: pendingSession.providerId, - ); + if (!walletAddedDuringDiscovery) { + await _addNwcWallet( + ndkFlutter, + nwcUri: constructedNwcUri, + walletName: pendingSession.walletName, + providerId: pendingSession.providerId, + requireAuthenticatedResponse: pendingSession.allowUntaggedInfoEvent, + ); + } _pendingSession = null; connectionState.value = WalletConnectionState.connected( @@ -897,7 +1276,7 @@ class NwcWalletAuthCoordinator { ); _retryLaunch = null; - if (!context.mounted) return true; + if (!showMessages || !context.mounted) return true; scaffoldMessenger!.showSnackBar( SnackBar( content: Text(l10n!.nwcWalletAdded), @@ -906,11 +1285,12 @@ class NwcWalletAuthCoordinator { ); return true; } on TimeoutException { + if (!identical(_pendingSession, pendingSession)) return false; _markFailed( pendingSession.walletName, 'Timed out while waiting for wallet connection info from ${pendingSession.discoveryRelay}', ); - if (!context.mounted) return true; + if (!showMessages || !context.mounted) return true; scaffoldMessenger!.showSnackBar( SnackBar( content: Text( @@ -923,8 +1303,9 @@ class NwcWalletAuthCoordinator { ); return true; } catch (e) { + if (!identical(_pendingSession, pendingSession)) return false; _markFailed(pendingSession.walletName, e); - if (!context.mounted) return true; + if (!showMessages || !context.mounted) return true; scaffoldMessenger!.showSnackBar( SnackBar( content: Text(l10n!.error(e.toString())), @@ -933,6 +1314,10 @@ class NwcWalletAuthCoordinator { ); return true; } finally { + if (identical(_closeWalletAuthSubscription, closeSubscription)) { + _closeWalletAuthSubscription = null; + } + await closeSubscription?.call(); _isCompletingPendingSession = false; } } @@ -942,6 +1327,7 @@ class NwcWalletAuthCoordinator { required String nwcUri, required String walletName, String? providerId, + bool requireAuthenticatedResponse = false, }) async { final walletId = DateTime.now().millisecondsSinceEpoch.toString(); final nwcWallet = NwcWallet( @@ -950,6 +1336,10 @@ class NwcWalletAuthCoordinator { supportedUnits: {'sat'}, nwcUrl: nwcUri, providerId: providerId, + metadata: { + if (requireAuthenticatedResponse) + NwcWallet.kRequireAuthenticatedResponseMetadataKey: true, + }, ); await ndkFlutter.ndk.wallets.addWallet(nwcWallet); _lastConnectedWalletId = walletId; @@ -961,19 +1351,180 @@ class _PendingNwcWalletAuthSession { final String discoveryRelay; final String returnTo; final String walletName; + final String state; final String? walletServicePubkey; final String? providerId; + final bool allowUntaggedInfoEvent; const _PendingNwcWalletAuthSession({ required this.appKey, required this.discoveryRelay, required this.returnTo, required this.walletName, + required this.state, + required this.allowUntaggedInfoEvent, this.walletServicePubkey, this.providerId, }); } +class _NwcWalletAuthDiscoveryDialog extends StatefulWidget { + final Uri? authorizationUri; + final String walletName; + final NwcWalletAuthCoordinator coordinator; + final NdkFlutter ndkFlutter; + final bool showAlbyGoQrInstructions; + + const _NwcWalletAuthDiscoveryDialog({ + this.authorizationUri, + required this.walletName, + required this.coordinator, + required this.ndkFlutter, + this.showAlbyGoQrInstructions = false, + }); + + @override + State<_NwcWalletAuthDiscoveryDialog> createState() => + _NwcWalletAuthDiscoveryDialogState(); +} + +class _NwcWalletAuthDiscoveryDialogState + extends State<_NwcWalletAuthDiscoveryDialog> { + bool _waiting = false; + bool _closing = false; + + @override + void initState() { + super.initState(); + widget.coordinator.connectionState.addListener(_onConnectionStateChanged); + WidgetsBinding.instance.addPostFrameCallback((_) => _waitForConnection()); + } + + @override + void dispose() { + widget.coordinator.connectionState.removeListener( + _onConnectionStateChanged, + ); + if (!_closing && widget.coordinator.hasPendingSession) { + widget.coordinator.cancelPendingConnection(); + } + super.dispose(); + } + + void _onConnectionStateChanged() { + if (!mounted) return; + final state = widget.coordinator.connectionState.value; + if (state.phase == WalletConnectionPhase.connected && !_closing) { + _closing = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) Navigator.of(context).pop(true); + }); + return; + } + setState(() {}); + } + + Future _waitForConnection() async { + if (_waiting) return; + setState(() => _waiting = true); + try { + await widget.coordinator.completePendingWalletAuth( + context, + widget.ndkFlutter, + timeout: null, + showMessages: false, + ); + } finally { + if (mounted) setState(() => _waiting = false); + } + } + + void _cancel() { + widget.coordinator.cancelPendingConnection(); + Navigator.of(context).pop(false); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final state = widget.coordinator.connectionState.value; + final failed = state.phase == WalletConnectionPhase.failed; + + return AlertDialog( + title: Text(l10n.walletConnectionFinishIn(widget.walletName)), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 340), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.authorizationUri != null) ...[ + Semantics( + label: l10n.scanNwcQrCodeTitle, + child: ColoredBox( + color: Colors.white, + child: Padding( + padding: const EdgeInsets.all(12), + child: SizedBox.square( + dimension: 300, + child: PrettyQrView.data( + data: widget.authorizationUri.toString(), + decoration: const PrettyQrDecoration( + quietZone: PrettyQrQuietZone.standard, + shape: PrettyQrSmoothSymbol(roundFactor: 0), + ), + ), + ), + ), + ), + ), + if (widget.showAlbyGoQrInstructions) ...[ + const SizedBox(height: 12), + Text( + l10n.albyGoQrScanInstructions, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + const SizedBox(height: 16), + ], + if (_waiting) ...[ + const CircularProgressIndicator(), + const SizedBox(height: 12), + ], + Text( + failed + ? l10n.walletConnectionFailed(widget.walletName) + : l10n.fetchingWalletConnectionInfo, + textAlign: TextAlign.center, + ), + if (failed && state.error != null) ...[ + const SizedBox(height: 8), + Text( + state.error!, + maxLines: 3, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + ], + ), + ), + actions: [ + if (widget.authorizationUri != null) + TextButton.icon( + onPressed: () => Clipboard.setData( + ClipboardData(text: widget.authorizationUri.toString()), + ), + icon: const Icon(Icons.copy_outlined), + label: Text(l10n.copy), + ), + TextButton(onPressed: _cancel, child: Text(l10n.cancel)), + ], + ); + } +} + class _PendingNwcCallbackSession { final String returnTo; final String walletName; @@ -1838,61 +2389,61 @@ Future showAddWalletTypeDialog( AlbyGoConnectConfig albyGoConnectConfig = kDefaultAlbyGoConnectConfig, NwcWalletAuthCoordinator? nwcWalletAuthCoordinator, WalletInputScanner? walletInputScanner, - List nwcConnectionOptions = const [], - bool openScannerOnAdd = true, + WalletQrScannerBuilder? walletQrScannerBuilder, + List? nwcConnectionOptions, NwcUriScanner? nwcUriScanner, Bolt12InputScanner? bolt12InputScanner, }) async { final coordinator = nwcWalletAuthCoordinator ?? NwcWalletAuthCoordinator(); coordinator.resetTerminalConnectionState(); + final legacyScanner = nwcUriScanner ?? bolt12InputScanner; + final scanner = + walletInputScanner ?? + (legacyScanner == null + ? (context, configuration) => showWalletInputDialog( + context, + configuration, + qrScannerBuilder: walletQrScannerBuilder, + ) + : (BuildContext context, WalletInputScannerConfiguration _) async { + final value = await legacyScanner(context); + return value == null ? null : WalletInputScanResult.value(value); + }); return await showDialog( context: context, - builder: (dialogContext) => _AddWalletDialog( + builder: (dialogContext) => _AddWalletFlow( ndkFlutter: ndkFlutter, parentContext: context, albyGoConnectConfig: albyGoConnectConfig, nwcWalletAuthCoordinator: coordinator, - walletInputScanner: walletInputScanner, - legacyWalletInputScanner: walletInputScanner == null - ? nwcUriScanner ?? bolt12InputScanner - : null, - nwcUriScanner: nwcUriScanner, - bolt12InputScanner: bolt12InputScanner, - nwcConnectionOptions: nwcConnectionOptions, - openScannerOnAdd: openScannerOnAdd && walletInputScanner != null, + walletInputScanner: scanner, + nwcConnectionOptions: + nwcConnectionOptions ?? + defaultNwcConnectionOptions(config: albyGoConnectConfig), ), ) ?? false; } -class _AddWalletDialog extends StatefulWidget { +class _AddWalletFlow extends StatefulWidget { final NdkFlutter ndkFlutter; final BuildContext parentContext; final AlbyGoConnectConfig albyGoConnectConfig; final NwcWalletAuthCoordinator nwcWalletAuthCoordinator; - final WalletInputScanner? walletInputScanner; - final Future Function(BuildContext context)? - legacyWalletInputScanner; - final NwcUriScanner? nwcUriScanner; - final Bolt12InputScanner? bolt12InputScanner; + final WalletInputScanner walletInputScanner; final List nwcConnectionOptions; - final bool openScannerOnAdd; - const _AddWalletDialog({ + const _AddWalletFlow({ required this.ndkFlutter, required this.parentContext, required this.albyGoConnectConfig, required this.nwcWalletAuthCoordinator, required this.walletInputScanner, - required this.legacyWalletInputScanner, - required this.nwcUriScanner, - required this.bolt12InputScanner, required this.nwcConnectionOptions, - required this.openScannerOnAdd, }); @override - State<_AddWalletDialog> createState() => _AddWalletDialogState(); + State<_AddWalletFlow> createState() => _AddWalletFlowState(); } class _WalletInputPreview { @@ -1932,7 +2483,7 @@ class _WalletPreviewDetail { const _WalletPreviewDetail(this.label, this.value); } -class _AddWalletDialogState extends State<_AddWalletDialog> { +class _AddWalletFlowState extends State<_AddWalletFlow> { final _inputController = TextEditingController(); final _walletNameController = TextEditingController(); final _lnBitsUrlController = TextEditingController(); @@ -1942,17 +2493,14 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { bool _isAdding = false; bool _isResolvingDetails = false; _WalletInputPreview? _preview; - bool _showManualOptions = false; bool _scannerOpen = false; @override void initState() { super.initState(); - if (widget.openScannerOnAdd && widget.walletInputScanner != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _scan(); - }); - } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _scan(); + }); } @override @@ -1967,11 +2515,11 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { Future _scan({ WalletInputOrigin initialOrigin = WalletInputOrigin.scanner, }) async { - final scanner = widget.walletInputScanner; - if (scanner != null) { - if (_scannerOpen) return; - setState(() => _scannerOpen = true); - final result = await scanner( + if (_scannerOpen) return; + setState(() => _scannerOpen = true); + WalletInputScanResult? result; + try { + result = await widget.walletInputScanner( context, _scannerConfiguration( openWalletChooserInitially: @@ -1980,36 +2528,47 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { initialOrigin == WalletInputOrigin.cashuMintChooser, ), ); - if (!mounted) return; - setState(() => _scannerOpen = false); - if (result == null) { - if (widget.openScannerOnAdd) Navigator.of(context).pop(false); - return; - } - if (result.connectionStarted) { - widget.nwcWalletAuthCoordinator.cancelPendingConnection(); - Navigator.of(context).pop(true); - return; - } - if (result.lnBitsConnection case final connection?) { - await _prepareLnBitsPreview(connection); - return; - } - if (result.value != null) { - await _preparePreview( - result.value!, - manuallyEntered: result.manuallyEntered, - origin: result.origin, - cashuMintSuggestion: result.cashuMintSuggestion, - providerId: result.providerId, - ); + } catch (error) { + if (kDebugMode) { + debugPrint('[wallet-scan] scanner failed: ${error.runtimeType}'); } + if (mounted) _closeWithError(error); + return; + } finally { + if (mounted) setState(() => _scannerOpen = false); + } + if (!mounted) return; + if (result == null) { + Navigator.of(context).pop(false); + return; + } + if (result.connectionStarted) { + widget.nwcWalletAuthCoordinator.cancelPendingConnection(); + Navigator.of(context).pop(true); + return; + } + if (result.lnBitsConnection case final connection?) { + await _prepareLnBitsPreview(connection); return; } + if (result.value != null) { + await _preparePreview( + result.value!, + manuallyEntered: result.manuallyEntered, + origin: result.origin, + cashuMintSuggestion: result.cashuMintSuggestion, + providerId: result.providerId, + ); + } + } - final value = await widget.legacyWalletInputScanner?.call(context); - if (!mounted || value == null) return; - await _preparePreview(value); + void _closeWithError(Object error) { + final message = error.toString(); + final messenger = ScaffoldMessenger.maybeOf(widget.parentContext); + messenger?.showSnackBar( + SnackBar(content: Text(message), backgroundColor: Colors.red), + ); + Navigator.of(context).pop(false); } Future _prepareLnBitsPreview(LnBitsConnectionInput connection) async { @@ -2054,7 +2613,7 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { _walletNameController.text = preview.name; }); } catch (error) { - if (mounted) setState(() => _errorMessage = error.toString()); + if (mounted) _closeWithError(error); } finally { if (mounted) setState(() => _isResolvingDetails = false); } @@ -2084,13 +2643,9 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { _preview = null; _errorMessage = null; }); - if (widget.openScannerOnAdd && widget.walletInputScanner != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _scan(initialOrigin: origin); - } - }); - } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _scan(initialOrigin: origin); + }); } WalletInputScannerConfiguration _scannerConfiguration({ @@ -2103,7 +2658,7 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { !kIsWeb && (Platform.isAndroid || Platform.isIOS); if (showInstalledWallets) { - options.addAll([ + options.add( WalletScannerConnectionOption( id: 'installed-wallet', label: l10n.chooseWalletApp, @@ -2112,20 +2667,22 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { iconBuilder: (_) => const Icon(Icons.account_balance_wallet_outlined), connect: _launchInstalledWallet, ), - WalletScannerConnectionOption( - id: 'alby-go', - label: l10n.albyGoOption, - kind: WalletScannerConnectionKind.albyGo, - iconBuilder: (_) => Image.asset( - 'assets/images/albygo.png', - package: 'ndk_flutter', - width: 28, - height: 28, - ), - connect: _launchAlbyGo, - ), - ]); + ); } + options.add( + WalletScannerConnectionOption( + id: 'alby-go', + label: l10n.albyGoOption, + kind: WalletScannerConnectionKind.albyGo, + iconBuilder: (_) => Image.asset( + 'assets/images/albygo.png', + package: 'ndk_flutter', + width: 28, + height: 28, + ), + connect: _launchAlbyGo, + ), + ); for (final option in widget.nwcConnectionOptions) { options.add( @@ -2221,12 +2778,6 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { ); } - Future _paste() async { - final data = await Clipboard.getData(Clipboard.kTextPlain); - if (!mounted) return; - await _preparePreview(data?.text ?? '', manuallyEntered: true); - } - void _setInput(String value) { final normalized = _normalizeWalletInput(value); final kind = classifyWalletInput(normalized); @@ -2260,7 +2811,18 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { final input = _normalizeWalletInput(rawInput); _setInput(input); final kind = classifyWalletInput(input); - if (kind == null) return; + if (kDebugMode) { + debugPrint( + '[wallet-scan] preview input: ' + 'kind=${kind?.name ?? 'unsupported'}, characters=${input.length}', + ); + } + if (kind == null) { + _closeWithError( + _errorMessage ?? AppLocalizations.of(context)!.unsupportedWalletInput, + ); + return; + } setState(() { _isResolvingDetails = true; @@ -2284,9 +2846,15 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { offset: preview.name.length, ); }); + if (kDebugMode) { + debugPrint('[wallet-scan] confirmation ready: ${kind.name}'); + } } catch (error) { if (!mounted) return; - setState(() => _errorMessage = error.toString()); + if (kDebugMode) { + debugPrint('[wallet-scan] preview failed: ${error.runtimeType}'); + } + _closeWithError(error); } finally { if (mounted) setState(() => _isResolvingDetails = false); } @@ -2746,21 +3314,6 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { ); } - String _kindLabel(AppLocalizations l10n, WalletInputKind kind) { - return switch (kind) { - WalletInputKind.nwc => l10n.nwcWalletTypeTitle, - WalletInputKind.bolt12 => l10n.bolt12WalletTypeTitle, - WalletInputKind.lightningAddress => l10n.lightningAddressInputType, - WalletInputKind.cashuMint => l10n.cashuWalletTypeTitle, - WalletInputKind.lnBits => l10n.lnbitsWalletOption, - }; - } - - Future _connectOption(NwcConnectionOption option) async { - Navigator.of(context).pop(true); - await _launchConnectionOption(option); - } - Future _launchConnectionOption(NwcConnectionOption option) async { try { await option.connect( @@ -2780,11 +3333,6 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { } } - Future _chooseInstalledWallet() async { - Navigator.of(context).pop(true); - await _launchInstalledWallet(); - } - Future _launchInstalledWallet() async { await widget.nwcWalletAuthCoordinator.connectInstalledWallet( widget.parentContext, @@ -2792,11 +3340,6 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { ); } - Future _connectAlbyGo() async { - Navigator.of(context).pop(true); - await _launchAlbyGo(); - } - Future _launchAlbyGo() async { await widget.nwcWalletAuthCoordinator.connectAlbyGo( widget.parentContext, @@ -2805,36 +3348,6 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { ); } - Future _openManual(WalletType type) async { - Navigator.of(context).pop(true); - switch (type) { - case WalletType.NWC: - await showNwcConnectionOptionsDialog( - widget.parentContext, - widget.ndkFlutter, - albyGoConnectConfig: widget.albyGoConnectConfig, - nwcWalletAuthCoordinator: widget.nwcWalletAuthCoordinator, - nwcUriScanner: widget.nwcUriScanner, - ); - return; - case WalletType.BOLT12: - await showAddBolt12WalletDialog( - widget.parentContext, - widget.ndkFlutter, - bolt12InputScanner: widget.bolt12InputScanner, - ); - return; - case WalletType.LNURL: - await showAddLnurlWalletDialog(widget.parentContext, widget.ndkFlutter); - return; - case WalletType.CASHU: - await showAddCashuWalletDialog(widget.parentContext, widget.ndkFlutter); - return; - case WalletType.LNBITS: - return; - } - } - Widget _buildConfirmationDialog( BuildContext context, _WalletInputPreview preview, @@ -3067,254 +3580,26 @@ class _AddWalletDialogState extends State<_AddWalletDialog> { @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final theme = Theme.of(context); - final kind = _inputKind; - final showInstalledWallets = - !kIsWeb && (Platform.isAndroid || Platform.isIOS); final preview = _preview; - if (preview != null) { return _buildConfirmationDialog(context, preview); } - - if (widget.openScannerOnAdd && widget.walletInputScanner != null) { - return const SizedBox.shrink(); - } - - return Dialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - clipBehavior: Clip.antiAlias, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560, maxHeight: 720), - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(24, 20, 24, 24), - child: Column( + if (_isResolvingDetails) { + return const Dialog( + child: Padding( + padding: EdgeInsets.all(32), + child: Row( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Row( - children: [ - Expanded( - child: Text( - l10n.addWalletTitle, - style: theme.textTheme.headlineSmall, - ), - ), - IconButton( - onPressed: () => Navigator.of(context).pop(false), - tooltip: MaterialLocalizations.of( - context, - ).closeButtonTooltip, - icon: const Icon(Icons.close), - ), - ], - ), - Text( - l10n.addWalletDescription, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - if (widget.walletInputScanner != null || - widget.legacyWalletInputScanner != null) ...[ - const SizedBox(height: 24), - FilledButton.icon( - onPressed: _isAdding || _isResolvingDetails ? null : _scan, - icon: const Icon(Icons.qr_code_scanner), - label: Text(l10n.scanWalletQrCode), - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(52), - ), - ), - ], - if (showInstalledWallets || - widget.nwcConnectionOptions.isNotEmpty) ...[ - const SizedBox(height: 24), - Text( - l10n.connectWithWallet, - style: theme.textTheme.titleMedium, - ), - const SizedBox(height: 8), - if (showInstalledWallets) - _ConnectionOptionTile( - icon: const Icon(Icons.account_balance_wallet_outlined), - title: l10n.chooseWalletApp, - subtitle: l10n.chooseWalletAppDescription, - onTap: _chooseInstalledWallet, - ), - if (showInstalledWallets) ...[ - const SizedBox(height: 8), - _ConnectionOptionTile( - icon: Image.asset( - 'assets/images/albygo.png', - package: 'ndk_flutter', - width: 28, - height: 28, - ), - title: l10n.albyGoOption, - onTap: _connectAlbyGo, - ), - ], - for (final option in widget.nwcConnectionOptions) ...[ - const SizedBox(height: 8), - _ConnectionOptionTile( - icon: - option.iconBuilder?.call(context) ?? - const Icon(Icons.account_balance_wallet_outlined), - title: option.label, - subtitle: option.subtitle, - onTap: () => _connectOption(option), - ), - ], - ], - const SizedBox(height: 24), - Text(l10n.walletInput, style: theme.textTheme.titleMedium), - const SizedBox(height: 8), - TextField( - controller: _inputController, - onChanged: _onInputChanged, - enabled: !_isAdding && !_isResolvingDetails, - obscureText: kind == WalletInputKind.nwc, - enableSuggestions: kind != WalletInputKind.nwc, - autocorrect: false, - decoration: InputDecoration( - border: const OutlineInputBorder(), - hintText: l10n.walletInputHint, - errorText: _errorMessage, - suffixIcon: IconButton( - onPressed: _isAdding || _isResolvingDetails ? null : _paste, - tooltip: l10n.paste, - icon: const Icon(Icons.content_paste), - ), - ), - ), - if (kind != null) ...[ - const SizedBox(height: 10), - Row( - children: [ - Icon( - Icons.check_circle, - size: 18, - color: theme.colorScheme.primary, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - '${l10n.detected}: ${_kindLabel(l10n, kind)}', - style: theme.textTheme.bodyMedium, - ), - ), - ], - ), - const SizedBox(height: 12), - FilledButton( - onPressed: _isAdding || _isResolvingDetails - ? null - : () => _preparePreview( - _inputController.text, - manuallyEntered: true, - ), - child: _isResolvingDetails - ? const SizedBox.square( - dimension: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text(l10n.reviewWallet), - ), - ], - const SizedBox(height: 16), - TextButton.icon( - onPressed: _isAdding - ? null - : () => setState( - () => _showManualOptions = !_showManualOptions, - ), - icon: Icon( - _showManualOptions ? Icons.expand_less : Icons.expand_more, - ), - label: Text(l10n.manualWalletSetup), - ), - if (_showManualOptions) ...[ - const SizedBox(height: 8), - _ManualWalletTile( - title: l10n.nwcWalletTypeTitle, - icon: Icons.account_balance_wallet_outlined, - onTap: () => _openManual(WalletType.NWC), - ), - _ManualWalletTile( - title: l10n.lnurlWalletTypeTitle, - icon: Icons.bolt, - onTap: () => _openManual(WalletType.LNURL), - ), - _ManualWalletTile( - title: l10n.bolt12WalletTypeTitle, - icon: Icons.electric_bolt, - onTap: () => _openManual(WalletType.BOLT12), - ), - _ManualWalletTile( - title: l10n.cashuWalletTypeTitle, - icon: Icons.toll, - onTap: () => _openManual(WalletType.CASHU), - ), - ], + CircularProgressIndicator(), + SizedBox(width: 20), + Text('Loading wallet details…'), ], ), ), - ), - ); - } -} - -class _ConnectionOptionTile extends StatelessWidget { - final Widget icon; - final String title; - final String? subtitle; - final VoidCallback onTap; - - const _ConnectionOptionTile({ - required this.icon, - required this.title, - required this.onTap, - this.subtitle, - }); - - @override - Widget build(BuildContext context) { - return Material( - color: Theme.of(context).colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(12), - child: ListTile( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - leading: SizedBox.square(dimension: 32, child: Center(child: icon)), - title: Text(title), - subtitle: subtitle == null ? null : Text(subtitle!), - trailing: const Icon(Icons.chevron_right), - onTap: onTap, - ), - ); - } -} - -class _ManualWalletTile extends StatelessWidget { - final String title; - final IconData icon; - final VoidCallback onTap; - - const _ManualWalletTile({ - required this.title, - required this.icon, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return ListTile( - leading: Icon(icon), - title: Text(title), - trailing: const Icon(Icons.chevron_right), - onTap: onTap, - ); + ); + } + return const SizedBox.shrink(); } } diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_nwc_wallet_icon.dart b/packages/ndk_flutter/lib/widgets/wallets/n_nwc_wallet_icon.dart index 2013124d1..fb15f05bc 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_nwc_wallet_icon.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_nwc_wallet_icon.dart @@ -13,7 +13,7 @@ class NNwcWalletIcon extends StatelessWidget { return switch (wallet.providerId) { 'alby' => _BrandIconFrame( size: size, - backgroundColor: Colors.black, + backgroundColor: Colors.white, child: SvgPicture.asset( 'assets/images/albyhub.svg', package: 'ndk_flutter', 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 0cb23dcd5..56f6807e3 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card.dart @@ -91,6 +91,8 @@ class _NWalletCardState extends State GetBudgetResponse? _budgetResponse; bool _isFetchingBudget = false; bool _isRefreshingBalance = false; + bool _isWalletAvailable = true; + int _connectionCheckGeneration = 0; @override NdkFlutter get ndkFlutter => widget.ndkFlutter; @@ -109,6 +111,28 @@ class _NWalletCardState extends State _loadCustomColor(); _initializeNwcBalanceIfNeeded(); _fetchBudgetIfNeeded(); + _checkWalletConnection(); + } + + Future _checkWalletConnection() async { + final generation = ++_connectionCheckGeneration; + final walletId = widget.wallet.id; + try { + await widget.ndkFlutter.ndk.wallets + .reconnectWallet(walletId) + .timeout(const Duration(seconds: 10)); + if (mounted && + widget.wallet.id == walletId && + generation == _connectionCheckGeneration) { + setState(() => _isWalletAvailable = true); + } + } catch (_) { + if (mounted && + widget.wallet.id == walletId && + generation == _connectionCheckGeneration) { + setState(() => _isWalletAvailable = false); + } + } } void _initializeNwcBalanceIfNeeded() { @@ -174,6 +198,10 @@ class _NWalletCardState extends State _budgetResponse = budget; }); } + } catch (_) { + if (mounted) { + setState(() => _isWalletAvailable = false); + } } finally { _isFetchingBudget = false; } @@ -208,6 +236,11 @@ class _NWalletCardState extends State final isOldNwc = oldWidget.wallet is NwcWallet; final walletIdChanged = oldWidget.wallet.id != widget.wallet.id; + if (walletIdChanged) { + _isWalletAvailable = true; + _checkWalletConnection(); + } + if (isCurrentNwc) { _initializeNwcBalanceIfNeeded(); } @@ -266,6 +299,7 @@ class _NWalletCardState extends State (isNwc || isLnurl || isLnBits) && widget.wallet.canReceive && !widget.wallet.canSend; + final bool isWalletUnavailable = !_isWalletAvailable; final String walletName; if (isCashu) { @@ -340,7 +374,10 @@ class _NWalletCardState extends State ); } } - final Color shadowColor = gradientColors[0]; + final effectiveGradientColors = isWalletUnavailable + ? [Colors.grey.shade700, Colors.grey.shade500] + : gradientColors; + final Color shadowColor = effectiveGradientColors[0]; // Determine icon configuration based on wallet type final WalletIconConfig iconConfig; @@ -438,8 +475,40 @@ class _NWalletCardState extends State color: Colors.white.withAlpha(30), )); + const unavailableColorFilter = ColorFilter.matrix([ + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]); + final effectiveMainIcon = isWalletUnavailable + ? ColorFiltered(colorFilter: unavailableColorFilter, child: mainIcon) + : mainIcon; + final effectiveBackgroundWidget = isWalletUnavailable + ? ColorFiltered( + colorFilter: unavailableColorFilter, + child: backgroundWidget, + ) + : backgroundWidget; + return GestureDetector( - onTap: widget.onTap, + onTap: isWalletUnavailable ? null : widget.onTap, child: Container( width: widget.width, margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), @@ -448,7 +517,7 @@ class _NWalletCardState extends State gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: gradientColors, + colors: effectiveGradientColors, ), boxShadow: [ BoxShadow( @@ -463,7 +532,7 @@ class _NWalletCardState extends State ), child: Stack( children: [ - Positioned(right: -20, top: -20, child: backgroundWidget), + Positioned(right: -20, top: -20, child: effectiveBackgroundWidget), Padding( padding: const EdgeInsets.all(20), child: Column( @@ -473,7 +542,7 @@ class _NWalletCardState extends State Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - mainIcon, + effectiveMainIcon, if (showReceiveOnlyLabel) Expanded( child: Padding( @@ -567,7 +636,9 @@ class _NWalletCardState extends State ), ], SizedBox(height: showBudgetInfo ? 4 : 16), - isLnurl + isWalletUnavailable + ? _buildUnavailableInfo(context) + : isLnurl ? _buildLnurlInfo( context, widget.wallet as LnurlWallet, @@ -610,7 +681,7 @@ class _NWalletCardState extends State final actionItems = >[ PopupMenuItem( value: 'send', - enabled: widget.wallet.canSend, + enabled: !isWalletUnavailable && widget.wallet.canSend, child: Row( children: [ const Icon(Icons.send, size: 20), @@ -621,7 +692,7 @@ class _NWalletCardState extends State ), PopupMenuItem( value: 'receive', - enabled: widget.wallet.canReceive, + enabled: !isWalletUnavailable && widget.wallet.canReceive, child: Row( children: [ const Icon(Icons.download, size: 20), @@ -633,7 +704,7 @@ class _NWalletCardState extends State if (isCashuWallet) PopupMenuItem( value: 'reclaim', - enabled: reclaimable.isNotEmpty, + enabled: !isWalletUnavailable && reclaimable.isNotEmpty, child: Row( children: [ const Icon(Icons.replay, size: 20), @@ -670,7 +741,9 @@ class _NWalletCardState extends State if (widget.wallet.canReceive) PopupMenuItem( value: 'set_default_receive', - enabled: !widget.isDefaultForReceiving, + enabled: + !isWalletUnavailable && + !widget.isDefaultForReceiving, child: Row( children: [ Icon( @@ -694,7 +767,8 @@ class _NWalletCardState extends State if (widget.wallet.canSend) PopupMenuItem( value: 'set_default_send', - enabled: !widget.isDefaultForSending, + enabled: + !isWalletUnavailable && !widget.isDefaultForSending, child: Row( children: [ Icon( @@ -1261,6 +1335,43 @@ class _NWalletCardState extends State return normalized == null || normalized.isEmpty ? null : normalized; } + Widget _buildUnavailableInfo(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Row( + children: [ + const Icon(Icons.cloud_off_outlined, color: Colors.white, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + l10n.walletUnreachable, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white.withAlpha(220), + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + tooltip: l10n.retry, + color: Colors.white, + visualDensity: VisualDensity.compact, + onPressed: _isRefreshingBalance ? null : _refreshBalance, + icon: _isRefreshingBalance + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.refresh), + ), + ], + ); + } + Widget _buildBalance(BuildContext context) { final l10n = AppLocalizations.of(context)!; final numberFormatter = NumberFormat.decimalPattern( @@ -1328,14 +1439,43 @@ class _NWalletCardState extends State } Future _refreshBalance() async { + final generation = ++_connectionCheckGeneration; + final walletId = widget.wallet.id; + final wasUnavailable = !_isWalletAvailable; setState(() => _isRefreshingBalance = true); try { - await widget.ndkFlutter.ndk.wallets.refreshBalance(widget.wallet.id); - if (mounted) { - displaySuccess(AppLocalizations.of(context)!.balanceRefreshed); + await widget.ndkFlutter.ndk.wallets + .reconnectWallet(walletId) + .timeout(const Duration(seconds: 10)); + final canRefreshBalance = + widget.wallet is LnBitsWallet || + (widget.wallet is NwcWallet && + _nwcPermissions( + widget.wallet as NwcWallet, + ).contains(NwcMethod.GET_BALANCE.name)); + if (canRefreshBalance) { + await widget.ndkFlutter.ndk.wallets + .refreshBalance(walletId) + .timeout(const Duration(seconds: 10)); + } + if (mounted && + widget.wallet.id == walletId && + generation == _connectionCheckGeneration) { + setState(() => _isWalletAvailable = true); + final l10n = AppLocalizations.of(context)!; + displaySuccess( + wasUnavailable + ? l10n.walletConnectionConnected(widget.wallet.name) + : l10n.balanceRefreshed, + ); } } catch (error) { - if (mounted) displayError(error.toString()); + if (mounted && + widget.wallet.id == walletId && + generation == _connectionCheckGeneration) { + setState(() => _isWalletAvailable = false); + displayError(error.toString()); + } } finally { if (mounted) setState(() => _isRefreshingBalance = false); } diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_input_dialog.dart b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_input_dialog.dart new file mode 100644 index 000000000..886341de7 --- /dev/null +++ b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_input_dialog.dart @@ -0,0 +1,1799 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:ndk_flutter/ndk_flutter.dart'; +import 'package:ndk_flutter/l10n/app_localizations.dart' as ndk_l10n; + +/// Builds only the camera preview/decoder used by the wallet input dialogs. +/// +/// Report decoded text through [onScan] and camera failures through [onError]. +/// The widget must release camera resources when disposed. NDK removes it while +/// another input dialog or connection status is displayed and recreates it when +/// scanning resumes. No camera package is required by ndk_flutter. +typedef WalletQrScannerBuilder = + Widget Function( + BuildContext context, + ValueChanged onScan, + ValueChanged onError, + ); + +/// Opens the shared scanner, manual input, wallet and Cashu mint chooser UI. +/// Without [qrScannerBuilder], all non-camera input methods remain available. +Future showWalletInputDialog( + BuildContext context, + WalletInputScannerConfiguration configuration, { + WalletQrScannerBuilder? qrScannerBuilder, +}) { + return showDialog( + context: context, + builder: (_) => _WalletQrScannerScope( + builder: qrScannerBuilder, + child: _WalletQrScannerDialog(configuration: configuration), + ), + ); +} + +class _WalletQrScannerScope extends InheritedWidget { + final WalletQrScannerBuilder? builder; + + const _WalletQrScannerScope({required this.builder, required super.child}); + + static WalletQrScannerBuilder? of(BuildContext context) => context + .dependOnInheritedWidgetOfExactType<_WalletQrScannerScope>() + ?.builder; + + @override + bool updateShouldNotify(_WalletQrScannerScope oldWidget) => + builder != oldWidget.builder; +} + +// Dialog routes do not inherit widgets from the launching route. Carry the +// camera adapter into every nested manual input, wallet and QR dialog. +Future _showWalletDialog({ + required BuildContext context, + required WidgetBuilder builder, +}) { + final scannerBuilder = _WalletQrScannerScope.of(context); + return showDialog( + context: context, + builder: (_) => _WalletQrScannerScope( + builder: scannerBuilder, + child: Builder(builder: builder), + ), + ); +} + +class _WalletQrScannerDialog extends StatefulWidget { + final WalletInputScannerConfiguration configuration; + + const _WalletQrScannerDialog({required this.configuration}); + + @override + State<_WalletQrScannerDialog> createState() => _WalletQrScannerDialogState(); +} + +class _WalletQrScannerDialogState extends State<_WalletQrScannerDialog> { + bool _hasScanned = false; + bool _cameraPaused = false; + String? _errorMessage; + bool _closingAfterSuccess = false; + + @override + void initState() { + super.initState(); + widget.configuration.connectionState.addListener(_onConnectionStateChanged); + if (widget.configuration.openWalletChooserInitially || + widget.configuration.openCashuMintChooserInitially) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _chooseWallet(); + }); + } + } + + @override + void dispose() { + widget.configuration.connectionState.removeListener( + _onConnectionStateChanged, + ); + super.dispose(); + } + + void _onConnectionStateChanged() { + if (!mounted) return; + final state = widget.configuration.connectionState.value; + setState(() {}); + if (state.phase == WalletConnectionPhase.connected && + !_closingAfterSuccess) { + _closingAfterSuccess = true; + final scannerRoute = ModalRoute.of(context); + if (scannerRoute != null) { + _closeAfterSuccessWhenCurrent( + scannerRoute, + const Duration(milliseconds: 1100), + ); + } + } + } + + void _closeAfterSuccessWhenCurrent( + ModalRoute scannerRoute, + Duration delay, + ) { + Future.delayed(delay, () { + if (!mounted || !scannerRoute.isActive) return; + if (!scannerRoute.isCurrent) { + _closeAfterSuccessWhenCurrent( + scannerRoute, + const Duration(milliseconds: 100), + ); + return; + } + Navigator.of( + context, + ).pop(const WalletInputScanResult.connectionStarted()); + }); + } + + void _onScan(String value) { + final normalized = value.trim(); + if (!mounted || _hasScanned || _cameraPaused || normalized.isEmpty) return; + setState(() => _hasScanned = true); + Navigator.of(context).pop(WalletInputScanResult.value(normalized)); + } + + void _onCameraError(Object error) { + if (!mounted || _errorMessage == error.toString()) return; + setState(() => _errorMessage = error.toString()); + } + + Future _openManualInput() async { + await _showManualInput(); + } + + Future _showManualInput({ + String initialValue = '', + bool nwcOnly = false, + }) async { + if (mounted) { + setState(() => _cameraPaused = true); + } + if (!mounted) return; + final result = await _showWalletDialog<_ManualWalletInputResult>( + context: context, + builder: (_) => _ManualWalletInputDialog( + initialValue: initialValue, + supportedInputDescription: + widget.configuration.supportedInputDescription, + nwcOnly: nwcOnly, + ), + ); + + if (!mounted) return; + if (result == null) { + setState(() => _cameraPaused = false); + return; + } + if (result.connectionStarted) { + Navigator.of( + context, + ).pop(const WalletInputScanResult.connectionStarted()); + return; + } + final value = result.value?.trim(); + if (value == null || value.isEmpty) return; + Navigator.of( + context, + ).pop(WalletInputScanResult.value(value, manuallyEntered: true)); + } + + Future _chooseWallet() async { + if (mounted) { + setState(() => _cameraPaused = true); + } + if (!mounted) return; + final result = await _showWalletDialog( + context: context, + builder: (_) => _WalletChooserDialog(configuration: widget.configuration), + ); + if (!mounted) return; + if (result == null) { + if (mounted) { + setState(() => _cameraPaused = false); + } + return; + } + if (!result.connectionStarted) Navigator.of(context).pop(result); + } + + Future _retryConnection() async { + await widget.configuration.retryPendingConnection(); + } + + Future _chooseOtherWallet() async { + widget.configuration.cancelPendingConnection(); + await _chooseWallet(); + } + + @override + Widget build(BuildContext context) { + final l10n = ndk_l10n.AppLocalizations.of(context)!; + final scannerBuilder = _WalletQrScannerScope.of(context); + final hasCamera = scannerBuilder != null; + + final connectionState = widget.configuration.connectionState.value; + return Dialog( + backgroundColor: Colors.black, + child: Stack( + children: [ + SizedBox( + width: 400, + height: hasCamera ? 680 : 520, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SizedBox(width: 48), + Expanded( + child: Text( + l10n.scanWalletQrCode, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + ), + textAlign: TextAlign.center, + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, color: Colors.white), + ), + ], + ), + ), + if (hasCamera) + Expanded( + child: Stack( + children: [ + if (_cameraPaused || + connectionState.phase != WalletConnectionPhase.idle) + const ColoredBox(color: Colors.black) + else + scannerBuilder(context, _onScan, _onCameraError), + Center( + child: Container( + width: 250, + height: 250, + decoration: BoxDecoration( + border: Border.all(color: Colors.white, width: 2), + borderRadius: BorderRadius.circular(12), + ), + ), + ), + if (_errorMessage != null) _buildErrorMessage(), + if (_hasScanned) + Container( + color: Colors.black.withValues(alpha: 0.7), + child: const Center( + child: CircularProgressIndicator( + color: Colors.white, + ), + ), + ), + ], + ), + ) + else + Expanded( + child: Stack( + children: [ + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + l10n.cameraNotAvailable, + style: const TextStyle(color: Colors.white70), + textAlign: TextAlign.center, + ), + ), + ), + if (_errorMessage != null) _buildErrorMessage(), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.configuration.supportedInputDescription, + style: const TextStyle(color: Colors.white70), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: _hasScanned ? null : _openManualInput, + icon: const Icon(Icons.paste), + label: FittedBox( + fit: BoxFit.scaleDown, + child: Text(l10n.pasteOrEnter), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of( + context, + ).colorScheme.primary, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(48), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: _hasScanned ? null : _chooseWallet, + icon: const Icon(Icons.account_balance_wallet), + label: Text(l10n.chooseWallet), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white38), + minimumSize: const Size.fromHeight(48), + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + if (connectionState.phase != WalletConnectionPhase.idle) + Positioned.fill( + child: _ConnectionStatusOverlay( + state: connectionState, + onRetry: _retryConnection, + onChooseOtherWallet: _chooseOtherWallet, + onCancel: widget.configuration.cancelPendingConnection, + ), + ), + ], + ), + ); + } + + Widget _buildErrorMessage() { + return Positioned( + top: 20, + left: 20, + right: 20, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _errorMessage!, + style: const TextStyle(color: Colors.white), + textAlign: TextAlign.center, + ), + ), + ); + } +} + +class _ConnectionStatusOverlay extends StatelessWidget { + final WalletConnectionState state; + final Future Function() onRetry; + final Future Function() onChooseOtherWallet; + final VoidCallback onCancel; + + const _ConnectionStatusOverlay({ + required this.state, + required this.onRetry, + required this.onChooseOtherWallet, + required this.onCancel, + }); + + @override + Widget build(BuildContext context) { + final l10n = ndk_l10n.AppLocalizations.of(context)!; + final failed = state.phase == WalletConnectionPhase.failed; + final connected = state.phase == WalletConnectionPhase.connected; + final walletName = state.walletName ?? l10n.unknownWalletType; + + return ColoredBox( + color: Colors.black.withValues(alpha: 0.94), + child: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (connected) + TweenAnimationBuilder( + tween: Tween(begin: 0.4, end: 1), + duration: const Duration(milliseconds: 420), + curve: Curves.easeOutBack, + builder: (context, scale, child) => + Transform.scale(scale: scale, child: child), + child: const Icon( + Icons.check_circle, + color: Colors.greenAccent, + size: 88, + ), + ) + else if (failed) + const Icon( + Icons.error_outline, + color: Colors.redAccent, + size: 72, + ) + else + const CircularProgressIndicator(color: Colors.white), + const SizedBox(height: 24), + Text( + connected + ? l10n.walletConnectionConnected(walletName) + : failed + ? l10n.walletConnectionFailed(walletName) + : state.phase == WalletConnectionPhase.awaitingReturn + ? l10n.walletConnectionFinishIn(walletName) + : l10n.walletConnectionConnecting(walletName), + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + if (failed) ...[ + const SizedBox(height: 12), + if (state.error case final error?) + Text( + error, + style: const TextStyle(color: Colors.white70), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: onRetry, + child: Text(l10n.retry), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: onChooseOtherWallet, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white38), + ), + child: Text(l10n.chooseAnotherWallet), + ), + ), + ] else if (!connected) ...[ + const SizedBox(height: 24), + OutlinedButton( + onPressed: onCancel, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white38), + minimumSize: const Size.fromHeight(48), + ), + child: Text(l10n.cancel), + ), + ], + ], + ), + ), + ), + ); + } +} + +class _ManualWalletInputDialog extends StatefulWidget { + final String initialValue; + final String supportedInputDescription; + final bool nwcOnly; + final Future Function()? connectWalletApp; + + const _ManualWalletInputDialog({ + required this.initialValue, + required this.supportedInputDescription, + required this.nwcOnly, + this.connectWalletApp, + }); + + @override + State<_ManualWalletInputDialog> createState() => + _ManualWalletInputDialogState(); +} + +class _ManualWalletInputDialogState extends State<_ManualWalletInputDialog> { + late final TextEditingController _controller; + WalletInputKind? _kind; + bool _isLaunchingWallet = false; + bool _isScanningQr = false; + + bool get _isValid => + _kind != null && (!widget.nwcOnly || _kind == WalletInputKind.nwc); + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialValue.trim()); + _kind = classifyWalletInput(_controller.text); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + String _kindLabel(ndk_l10n.AppLocalizations l10n, WalletInputKind kind) { + return switch (kind) { + WalletInputKind.nwc => l10n.nwcWalletTypeTitle, + WalletInputKind.bolt12 => l10n.bolt12WalletTypeTitle, + WalletInputKind.lightningAddress => l10n.lightningAddressInputType, + WalletInputKind.cashuMint => l10n.cashuWalletTypeTitle, + WalletInputKind.lnBits => l10n.lnbitsWalletOption, + }; + } + + Future _connectWalletApp() async { + final connect = widget.connectWalletApp; + if (connect == null || _isLaunchingWallet) return; + setState(() => _isLaunchingWallet = true); + try { + await connect(); + if (!mounted) return; + Navigator.of( + context, + ).pop(const _ManualWalletInputResult.connectionStarted()); + } finally { + if (mounted) setState(() => _isLaunchingWallet = false); + } + } + + Future _scanQrCode() async { + if (_isScanningQr) return; + setState(() => _isScanningQr = true); + try { + final value = await _showWalletDialog( + context: context, + builder: (_) => const _QrCodeScannerDialog(), + ); + if (!mounted || value == null) return; + _controller.text = value; + _controller.selection = TextSelection.collapsed(offset: value.length); + setState(() => _kind = classifyWalletInput(value)); + } finally { + if (mounted) setState(() => _isScanningQr = false); + } + } + + Future _pasteInput() async { + final clipboardData = await Clipboard.getData(Clipboard.kTextPlain); + if (!mounted) return; + final value = clipboardData?.text?.trim() ?? ''; + _controller.text = value; + _controller.selection = TextSelection.collapsed(offset: value.length); + setState(() => _kind = classifyWalletInput(value)); + } + + void _clearInput() { + _controller.clear(); + setState(() => _kind = null); + } + + @override + Widget build(BuildContext context) { + final l10n = ndk_l10n.AppLocalizations.of(context)!; + final kind = _kind; + final hasInput = _controller.text.trim().isNotEmpty; + final isNwcInput = kind == WalletInputKind.nwc; + + return AlertDialog( + title: Text(widget.nwcOnly ? l10n.manualNwcConnection : l10n.walletInput), + content: SizedBox( + width: 480, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _controller, + autofocus: true, + autocorrect: false, + obscureText: isNwcInput, + enableSuggestions: !isNwcInput, + keyboardType: TextInputType.url, + minLines: isNwcInput ? 1 : 2, + maxLines: isNwcInput ? 1 : 4, + onChanged: (value) { + setState(() => _kind = classifyWalletInput(value)); + }, + decoration: InputDecoration( + border: const OutlineInputBorder(), + hintText: widget.nwcOnly + ? l10n.nwcConnectionUriHint + : widget.supportedInputDescription, + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: _pasteInput, + tooltip: l10n.paste, + icon: const Icon(Icons.content_paste_outlined), + ), + if (widget.nwcOnly && + _WalletQrScannerScope.of(context) != null) + IconButton( + onPressed: _isScanningQr ? null : _scanQrCode, + tooltip: l10n.scanWalletQrCode, + icon: _isScanningQr + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.qr_code_scanner), + ), + if (hasInput) + IconButton( + onPressed: _clearInput, + tooltip: l10n.clearInput, + icon: const Icon(Icons.clear), + ), + ], + ), + ), + ), + if (widget.nwcOnly && widget.connectWalletApp != null) ...[ + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _isLaunchingWallet ? null : _connectWalletApp, + icon: _isLaunchingWallet + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.account_balance_wallet_outlined), + label: Text(l10n.oneClickConnect), + ), + ), + ], + if (hasInput) ...[ + const SizedBox(height: 12), + Row( + children: [ + Icon( + _isValid ? Icons.check_circle : Icons.error_outline, + size: 18, + color: _isValid + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.error, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _isValid + ? '${l10n.detected}: ${_kindLabel(l10n, kind!)}' + : l10n.unsupportedWalletInput, + ), + ), + ], + ), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: _isValid + ? () => Navigator.of( + context, + ).pop(_ManualWalletInputResult.value(_controller.text.trim())) + : null, + child: Text(l10n.reviewWallet), + ), + ], + ); + } +} + +class _ManualWalletInputResult { + final String? value; + final bool connectionStarted; + + const _ManualWalletInputResult.value(this.value) : connectionStarted = false; + + const _ManualWalletInputResult.connectionStarted() + : value = null, + connectionStarted = true; +} + +class _QrCodeScannerDialog extends StatefulWidget { + const _QrCodeScannerDialog(); + + @override + State<_QrCodeScannerDialog> createState() => _QrCodeScannerDialogState(); +} + +class _QrCodeScannerDialogState extends State<_QrCodeScannerDialog> { + bool _hasScanned = false; + String? _error; + + void _complete(String? value) { + final normalized = value?.trim(); + if (!mounted || _hasScanned || normalized == null || normalized.isEmpty) { + return; + } + _hasScanned = true; + Navigator.of(context).pop(normalized); + } + + void _onCameraError(Object error) { + if (!mounted || _error == error.toString()) return; + setState(() => _error = error.toString()); + } + + @override + Widget build(BuildContext context) { + final scannerBuilder = _WalletQrScannerScope.of(context); + final l10n = ndk_l10n.AppLocalizations.of(context)!; + return Dialog( + backgroundColor: Colors.black, + child: SizedBox( + width: 400, + height: 560, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Expanded( + child: Text( + l10n.scanWalletQrCode, + style: const TextStyle(color: Colors.white, fontSize: 18), + textAlign: TextAlign.center, + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, color: Colors.white), + ), + ], + ), + ), + Expanded( + child: Stack( + fit: StackFit.expand, + children: [ + if (scannerBuilder != null) + scannerBuilder(context, _complete, _onCameraError) + else + Center( + child: Text( + l10n.cameraNotAvailable, + style: const TextStyle(color: Colors.white70), + textAlign: TextAlign.center, + ), + ), + Center( + child: Container( + width: 250, + height: 250, + decoration: BoxDecoration( + border: Border.all(color: Colors.white, width: 2), + borderRadius: BorderRadius.circular(12), + ), + ), + ), + if (_error != null) + Align( + alignment: Alignment.bottomCenter, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + color: Colors.black87, + child: Text( + _error!, + style: const TextStyle(color: Colors.white), + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _WalletChooserDialog extends StatefulWidget { + final WalletInputScannerConfiguration configuration; + + const _WalletChooserDialog({required this.configuration}); + + @override + State<_WalletChooserDialog> createState() => _WalletChooserDialogState(); +} + +class _WalletChooserDialogState extends State<_WalletChooserDialog> { + WalletInputScannerConfiguration get configuration => widget.configuration; + + @override + void initState() { + super.initState(); + if (configuration.openCashuMintChooserInitially) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _openCashu(context); + }); + } + } + + WalletScannerConnectionOption? get _coinosOption { + for (final option in configuration.connectionOptions) { + if (option.id == 'coinos') return option; + } + return null; + } + + WalletScannerConnectionOption? get _installedWalletOption { + for (final option in configuration.connectionOptions) { + if (option.kind == WalletScannerConnectionKind.installedWallet) { + return option; + } + } + return null; + } + + Future _manualNwc(BuildContext context) async { + final result = await _showWalletDialog<_ManualWalletInputResult>( + context: context, + builder: (_) => _ManualWalletInputDialog( + initialValue: '', + supportedInputDescription: configuration.supportedInputDescription, + nwcOnly: true, + connectWalletApp: _installedWalletOption?.connect, + ), + ); + if (result != null && context.mounted) { + Navigator.of(context).pop( + result.connectionStarted + ? const WalletInputScanResult.connectionStarted() + : WalletInputScanResult.value( + result.value, + manuallyEntered: true, + origin: WalletInputOrigin.walletChooser, + ), + ); + } + } + + Future _connectCoinos(BuildContext context) async { + final option = _coinosOption; + if (option == null) return; + await option.connect(); + if (context.mounted && + configuration.connectionState.value.phase != + WalletConnectionPhase.idle) { + Navigator.of( + context, + ).pop(const WalletInputScanResult.connectionStarted()); + } + } + + Future _openAlby(BuildContext context) async { + final result = await _showWalletDialog( + context: context, + builder: (_) => _AlbyChooserDialog(configuration: configuration), + ); + if (result != null && context.mounted) Navigator.of(context).pop(result); + } + + Future _openCashu(BuildContext context) async { + final result = await _showWalletDialog( + context: context, + builder: (_) => _CashuMintChooserDialog(configuration: configuration), + ); + if (result != null && context.mounted) Navigator.of(context).pop(result); + } + + Future _openLnBits(BuildContext context) async { + final result = await _showWalletDialog( + context: context, + builder: (_) => _LnBitsConnectionDialog( + validate: configuration.validateLnBitsConnection, + ), + ); + if (result != null && context.mounted) { + Navigator.of(context).pop(WalletInputScanResult.lnBits(result)); + } + } + + void _returnToScanner(BuildContext context) { + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final l10n = ndk_l10n.AppLocalizations.of(context)!; + + return AlertDialog( + title: Row( + children: [ + Expanded(child: Text(l10n.chooseWallet)), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + content: SizedBox( + width: 440, + child: GridView.count( + shrinkWrap: true, + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + mainAxisExtent: 148, + children: [ + if (_WalletQrScannerScope.of(context) != null) + _WalletGridTile( + label: l10n.scanWalletQrCode, + icon: const _BrandIconFrame( + backgroundColor: Colors.black, + child: Icon( + Icons.qr_code_scanner, + color: Colors.white, + size: 42, + ), + ), + onTap: () => _returnToScanner(context), + ), + _WalletGridTile( + label: l10n.albyWalletOption, + icon: const _AlbyHubIcon(), + onTap: () => _openAlby(context), + ), + if (_coinosOption != null) + _WalletGridTile( + label: l10n.coinosWalletOption, + icon: const _CoinosIcon(), + onTap: () => _connectCoinos(context), + ), + _WalletGridTile( + label: l10n.cashuOption, + icon: const _BrandIconFrame( + backgroundColor: Color(0xFFFFF3D7), + child: Image( + image: AssetImage( + 'assets/images/cashu.png', + package: 'ndk_flutter', + ), + width: 44, + height: 44, + ), + ), + onTap: () => _openCashu(context), + ), + _WalletGridTile( + label: 'NWC', + icon: const _NwcIcon(), + onTap: () => _manualNwc(context), + ), + _WalletGridTile( + label: l10n.lnbitsWalletOption, + icon: const _LnBitsIcon(), + onTap: () => _openLnBits(context), + ), + for (final option in configuration.connectionOptions) + if (option.kind == WalletScannerConnectionKind.custom && + option.id != 'alby-cloud' && + option.id != 'coinos') + _WalletGridTile( + label: option.label, + icon: + option.iconBuilder?.call(context) ?? + const Icon(Icons.account_balance_wallet_outlined), + onTap: () async { + await option.connect(); + if (context.mounted) { + Navigator.of( + context, + ).pop(const WalletInputScanResult.connectionStarted()); + } + }, + ), + ], + ), + ), + ); + } +} + +class _LnBitsConnectionDialog extends StatefulWidget { + final Future Function(LnBitsConnectionInput input)? + validate; + + const _LnBitsConnectionDialog({this.validate}); + + @override + State<_LnBitsConnectionDialog> createState() => + _LnBitsConnectionDialogState(); +} + +class _LnBitsConnectionDialogState extends State<_LnBitsConnectionDialog> { + final _adminKeyController = TextEditingController(); + final _urlController = TextEditingController(text: 'https://'); + bool _showAdminKey = false; + bool _readOnly = false; + bool _isValidating = false; + String? _error; + + Future _scanInto(TextEditingController controller) async { + final value = await _showWalletDialog( + context: context, + builder: (_) => const _QrCodeScannerDialog(), + ); + if (!mounted || value == null) return; + final normalized = value.trim(); + controller.text = normalized; + controller.selection = TextSelection.collapsed(offset: normalized.length); + setState(() => _error = null); + } + + @override + void dispose() { + _adminKeyController.dispose(); + _urlController.dispose(); + super.dispose(); + } + + Future _continue() async { + final l10n = ndk_l10n.AppLocalizations.of(context)!; + final adminKey = _adminKeyController.text.trim(); + final url = _urlController.text.trim(); + if (adminKey.isEmpty || url.isEmpty || url == 'https://') { + setState(() => _error = l10n.lnbitsCredentialsRequired); + return; + } + final input = LnBitsConnectionInput( + url: url, + adminKey: adminKey, + readOnly: _readOnly, + ); + final validate = widget.validate; + if (validate == null) { + Navigator.of(context).pop(input); + return; + } + setState(() { + _isValidating = true; + _error = null; + }); + try { + final validated = await validate(input); + if (mounted) Navigator.of(context).pop(validated); + } catch (error) { + if (mounted) setState(() => _error = error.toString()); + } finally { + if (mounted) setState(() => _isValidating = false); + } + } + + @override + Widget build(BuildContext context) { + final l10n = ndk_l10n.AppLocalizations.of(context)!; + return AlertDialog( + title: Row( + children: [ + const _LnBitsIcon(), + const SizedBox(width: 16), + Expanded(child: Text(l10n.lnbitsWalletOption)), + IconButton( + onPressed: _isValidating ? null : () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l10n.lnbitsConnectionInstructions), + const SizedBox(height: 20), + DropdownButtonFormField( + initialValue: _readOnly, + isExpanded: true, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.lnbitsKeyType, + ), + items: [ + DropdownMenuItem( + value: false, + child: Text(l10n.lnbitsAdminKey), + ), + DropdownMenuItem( + value: true, + child: Text(l10n.lnbitsInvoiceReadKey), + ), + ], + onChanged: _isValidating + ? null + : (value) => setState(() => _readOnly = value ?? false), + ), + if (_readOnly) ...[ + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.lock_outline, + size: 20, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded(child: Text(l10n.lnbitsReadOnlyDescription)), + ], + ), + ], + const SizedBox(height: 16), + TextField( + controller: _adminKeyController, + enabled: !_isValidating, + obscureText: !_showAdminKey, + enableSuggestions: false, + autocorrect: false, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: _readOnly + ? l10n.lnbitsInvoiceReadKey + : l10n.lnbitsAdminKey, + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: _isValidating + ? null + : () => _scanInto(_adminKeyController), + tooltip: l10n.scanWalletQrCode, + icon: const Icon(Icons.qr_code_scanner), + ), + IconButton( + onPressed: () => setState(() { + _showAdminKey = !_showAdminKey; + }), + icon: Icon( + _showAdminKey ? Icons.visibility_off : Icons.visibility, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + TextField( + controller: _urlController, + enabled: !_isValidating, + keyboardType: TextInputType.url, + autocorrect: false, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.lnbitsUrl, + suffixIcon: IconButton( + onPressed: _isValidating + ? null + : () => _scanInto(_urlController), + tooltip: l10n.scanWalletQrCode, + icon: const Icon(Icons.qr_code_scanner), + ), + ), + ), + if (_error != null) ...[ + const SizedBox(height: 12), + Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: _isValidating ? null : () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: _isValidating ? null : _continue, + child: _isValidating + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.reviewWallet), + ), + ], + ); + } +} + +class _CashuMintChooserDialog extends StatefulWidget { + final WalletInputScannerConfiguration configuration; + + const _CashuMintChooserDialog({required this.configuration}); + + @override + State<_CashuMintChooserDialog> createState() => + _CashuMintChooserDialogState(); +} + +class _CashuMintChooserDialogState extends State<_CashuMintChooserDialog> { + late Future> _suggestions; + + @override + void initState() { + super.initState(); + _suggestions = widget.configuration.discoverCashuMints(); + } + + void _retry() { + setState(() { + _suggestions = widget.configuration.discoverCashuMints(); + }); + } + + Future _enterMintUrl() async { + final l10n = ndk_l10n.AppLocalizations.of(context)!; + final result = await _showWalletDialog<_ManualWalletInputResult>( + context: context, + builder: (_) => _ManualWalletInputDialog( + initialValue: 'https://', + supportedInputDescription: l10n.enterMintUrl, + nwcOnly: false, + ), + ); + final value = result?.value?.trim(); + if (!mounted || value == null) return; + if (classifyWalletInput(value) != WalletInputKind.cashuMint) return; + Navigator.of(context).pop( + WalletInputScanResult.value( + value, + manuallyEntered: true, + origin: WalletInputOrigin.cashuMintChooser, + ), + ); + } + + void _selectMint(CashuMintSuggestion mint) { + Navigator.of(context).pop( + WalletInputScanResult.value( + mint.url, + origin: WalletInputOrigin.cashuMintChooser, + cashuMintSuggestion: mint, + ), + ); + } + + @override + Widget build(BuildContext context) { + final l10n = ndk_l10n.AppLocalizations.of(context)!; + return AlertDialog( + title: Row( + children: [ + Expanded(child: Text(l10n.chooseCashuMint)), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + content: SizedBox( + width: 460, + height: 480, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.cashuMintRatingsNotice, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + Expanded( + child: FutureBuilder>( + future: _suggestions, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.cashuMintDiscoveryFailed, + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _retry, + icon: const Icon(Icons.refresh), + label: Text(l10n.retry), + ), + ], + ), + ); + } + final suggestions = snapshot.data ?? const []; + if (suggestions.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.noCashuMintSuggestions, + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _retry, + icon: const Icon(Icons.refresh), + label: Text(l10n.retry), + ), + ], + ), + ); + } + return ListView.separated( + scrollCacheExtent: const ScrollCacheExtent.pixels(0), + itemCount: suggestions.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final mint = suggestions[index]; + return _CashuMintListTile( + mint: mint, + enrich: widget.configuration.enrichCashuMint, + onSelected: _selectMint, + ); + }, + ); + }, + ), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _enterMintUrl, + icon: const Icon(Icons.edit_outlined), + label: Text(l10n.enterMintUrlManually), + ), + ], + ), + ), + ); + } +} + +class _CashuMintListTile extends StatefulWidget { + final CashuMintSuggestion mint; + final Future Function(CashuMintSuggestion mint) enrich; + final ValueChanged onSelected; + + const _CashuMintListTile({ + required this.mint, + required this.enrich, + required this.onSelected, + }); + + @override + State<_CashuMintListTile> createState() => _CashuMintListTileState(); +} + +class _CashuMintListTileState extends State<_CashuMintListTile> { + late final Future _enriched; + bool _selecting = false; + + @override + void initState() { + super.initState(); + _enriched = widget.enrich(widget.mint); + } + + Future _select() async { + if (_selecting) return; + setState(() => _selecting = true); + CashuMintSuggestion mint; + try { + mint = await _enriched; + } catch (_) { + mint = widget.mint; + } + if (mounted) widget.onSelected(mint); + } + + @override + Widget build(BuildContext context) { + final l10n = ndk_l10n.AppLocalizations.of(context)!; + return FutureBuilder( + future: _enriched, + initialData: widget.mint, + builder: (context, snapshot) { + final mint = snapshot.data ?? widget.mint; + final rating = mint.averageRating; + return ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 4, + ), + leading: _CashuMintIcon(mint: mint), + title: Text(mint.name, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(mint.url, maxLines: 1, overflow: TextOverflow.ellipsis), + Text( + rating == null + ? l10n.noRatingsYet + : l10n.cashuMintRating( + rating.toStringAsFixed(1), + mint.reviewsCount, + ), + ), + ], + ), + trailing: _selecting + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.chevron_right), + onTap: _selecting ? null : _select, + ); + }, + ); + } +} + +class _AlbyChooserDialog extends StatelessWidget { + final WalletInputScannerConfiguration configuration; + + const _AlbyChooserDialog({required this.configuration}); + + WalletScannerConnectionOption? get _albyGoOption { + for (final option in configuration.connectionOptions) { + if (option.kind == WalletScannerConnectionKind.albyGo) return option; + } + return null; + } + + WalletScannerConnectionOption? get _albyCloudOption { + for (final option in configuration.connectionOptions) { + if (option.id == 'alby-cloud') return option; + } + return null; + } + + Future _openCloud(BuildContext context) async { + final option = _albyCloudOption; + if (option == null) return; + await option.connect(); + if (context.mounted) { + Navigator.of( + context, + ).pop(const WalletInputScanResult.connectionStarted()); + } + } + + Future _connectAlbyGo(BuildContext context) async { + final option = _albyGoOption; + if (option == null) return; + await option.connect(); + if (context.mounted && + configuration.connectionState.value.phase != + WalletConnectionPhase.idle) { + Navigator.of( + context, + ).pop(const WalletInputScanResult.connectionStarted()); + } + } + + Future _manualNwc(BuildContext context) async { + final result = await _showWalletDialog<_ManualWalletInputResult>( + context: context, + builder: (_) => _ManualWalletInputDialog( + initialValue: '', + supportedInputDescription: configuration.supportedInputDescription, + nwcOnly: true, + ), + ); + if (result?.value != null && context.mounted) { + Navigator.of(context).pop( + WalletInputScanResult.value( + result!.value, + manuallyEntered: true, + origin: WalletInputOrigin.walletChooser, + providerId: 'alby', + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final l10n = ndk_l10n.AppLocalizations.of(context)!; + return AlertDialog( + title: Text(l10n.albyWalletOption), + content: SizedBox( + width: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const _AlbyHubIcon(), + title: Text(l10n.albyCloudOption), + subtitle: const Text('my.albyhub.com'), + trailing: const Icon(Icons.open_in_new), + enabled: _albyCloudOption != null, + onTap: _albyCloudOption == null + ? null + : () => _openCloud(context), + ), + const SizedBox(height: 16), + ListTile( + leading: const _AlbyGoIcon(), + title: Text(l10n.albyGoOption), + trailing: const Icon(Icons.chevron_right), + enabled: _albyGoOption != null, + onTap: _albyGoOption == null + ? null + : () => _connectAlbyGo(context), + ), + const SizedBox(height: 16), + ListTile( + leading: const _NwcIcon(), + title: Text(l10n.manualNwcConnection), + trailing: const Icon(Icons.chevron_right), + onTap: () => _manualNwc(context), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + ], + ); + } +} + +class _AlbyHubIcon extends StatelessWidget { + const _AlbyHubIcon(); + + @override + Widget build(BuildContext context) { + return _BrandIconFrame( + backgroundColor: Colors.white, + child: SvgPicture.asset( + 'assets/images/albyhub.svg', + package: 'ndk_flutter', + width: 40, + height: 40, + ), + ); + } +} + +class _AlbyGoIcon extends StatelessWidget { + const _AlbyGoIcon(); + + @override + Widget build(BuildContext context) { + return _BrandIconFrame( + backgroundColor: Colors.white, + child: Image.asset( + 'assets/images/albygo.png', + package: 'ndk_flutter', + width: 40, + height: 40, + ), + ); + } +} + +class _CashuMintIcon extends StatelessWidget { + final CashuMintSuggestion mint; + + const _CashuMintIcon({required this.mint}); + + @override + Widget build(BuildContext context) { + final fallback = Icon( + Icons.toll_outlined, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ); + final iconUrl = mint.iconUrl; + + return CircleAvatar( + radius: 24, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + child: ClipOval( + child: iconUrl == null + ? fallback + : Image.network( + iconUrl, + width: 48, + height: 48, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => fallback, + ), + ), + ); + } +} + +class _CoinosIcon extends StatelessWidget { + const _CoinosIcon(); + + @override + Widget build(BuildContext context) { + return _BrandIconFrame( + backgroundColor: Colors.white, + child: SvgPicture.asset( + 'assets/images/coinos.svg', + package: 'ndk_flutter', + width: 48, + height: 48, + ), + ); + } +} + +class _NwcIcon extends StatelessWidget { + const _NwcIcon(); + + @override + Widget build(BuildContext context) { + return _BrandIconFrame( + backgroundColor: Colors.white, + child: Image.asset( + 'assets/images/nwc.png', + package: 'ndk_flutter', + width: 48, + height: 48, + fit: BoxFit.contain, + ), + ); + } +} + +class _LnBitsIcon extends StatelessWidget { + const _LnBitsIcon(); + + @override + Widget build(BuildContext context) { + return _BrandIconFrame( + backgroundColor: const Color(0xFF673AB7), + child: SvgPicture.asset( + 'assets/images/lnbits.svg', + package: 'ndk_flutter', + width: 64, + height: 64, + ), + ); + } +} + +class _BrandIconFrame extends StatelessWidget { + final Color backgroundColor; + final Widget child; + + const _BrandIconFrame({required this.backgroundColor, required this.child}); + + @override + Widget build(BuildContext context) { + final dark = Theme.of(context).brightness == Brightness.dark; + + return Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: dark + ? Colors.white.withValues(alpha: 0.15) + : Colors.black.withValues(alpha: 0.25), + offset: const Offset(0, 10), + blurRadius: 15, + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: ColoredBox( + color: backgroundColor, + child: Center(child: child), + ), + ), + ); + } +} + +class _WalletGridTile extends StatelessWidget { + final String label; + final Widget icon; + final VoidCallback onTap; + + const _WalletGridTile({ + required this.label, + required this.icon, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(16), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 12, 8, 8), + child: Column( + children: [ + icon, + const SizedBox(height: 8), + Expanded( + child: Center( + child: Tooltip( + message: label, + child: Text( + label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_wallets.dart b/packages/ndk_flutter/lib/widgets/wallets/n_wallets.dart index d632b8d5e..33964d08c 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_wallets.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_wallets.dart @@ -74,10 +74,12 @@ class NWallets extends StatefulWidget { final WalletInputScanner? walletInputScanner; /// Wallet apps or web services offering assisted NWC authorization. - final List nwcConnectionOptions; + /// Null enables Alby Cloud and Coinos presets; an empty list disables them. + final List? nwcConnectionOptions; - /// Whether to open the host scanner immediately on Android and iOS. - final bool openScannerOnAdd; + /// Camera preview/decoder for the shared wallet input UI. Optional: paste and + /// wallet selection work without any camera dependency. + final WalletQrScannerBuilder? walletQrScannerBuilder; /// Custom icon configuration for Cashu wallets final WalletIconConfig? cashuIcon; @@ -115,8 +117,8 @@ class NWallets extends StatefulWidget { this.nwcUriScanner, this.bolt12InputScanner, this.walletInputScanner, - this.nwcConnectionOptions = const [], - this.openScannerOnAdd = true, + this.nwcConnectionOptions, + this.walletQrScannerBuilder, this.cashuIcon, this.nwcIcon, this.lnurlIcon, @@ -136,6 +138,26 @@ class NWalletsState extends State { super.initState(); _nwcWalletAuthCoordinator = widget.nwcWalletAuthCoordinator ?? NwcWalletAuthCoordinator(); + _nwcWalletAuthCoordinator.connectionState.addListener( + _onWalletConnectionStateChanged, + ); + } + + @override + void dispose() { + _nwcWalletAuthCoordinator.connectionState.removeListener( + _onWalletConnectionStateChanged, + ); + super.dispose(); + } + + void _onWalletConnectionStateChanged() { + if (!mounted || + _nwcWalletAuthCoordinator.connectionState.value.phase != + WalletConnectionPhase.connected) { + return; + } + _selectConnectedWallet(); } Future onProtocolUrlReceived(String url) async { @@ -305,8 +327,8 @@ class NWalletsState extends State { albyGoConnectConfig: widget.albyGoConnectConfig, nwcWalletAuthCoordinator: _nwcWalletAuthCoordinator, walletInputScanner: widget.walletInputScanner, + walletQrScannerBuilder: widget.walletQrScannerBuilder, nwcConnectionOptions: widget.nwcConnectionOptions, - openScannerOnAdd: widget.openScannerOnAdd, nwcUriScanner: widget.nwcUriScanner, bolt12InputScanner: widget.bolt12InputScanner, ); diff --git a/packages/ndk_flutter/lib/widgets/widgets.dart b/packages/ndk_flutter/lib/widgets/widgets.dart index e41d5019b..0c0a9b0a9 100644 --- a/packages/ndk_flutter/lib/widgets/widgets.dart +++ b/packages/ndk_flutter/lib/widgets/widgets.dart @@ -15,3 +15,4 @@ export 'wallets/n_recent_transactions.dart'; export 'wallets/n_wallet_actions.dart'; export 'wallets/n_add_wallet_dialogs.dart'; export 'wallets/n_lnbits_icon.dart'; +export 'wallets/n_wallet_input_dialog.dart'; diff --git a/packages/ndk_flutter/test/wallet_input_classifier_test.dart b/packages/ndk_flutter/test/wallet_input_classifier_test.dart index 00e35847f..21caad154 100644 --- a/packages/ndk_flutter/test/wallet_input_classifier_test.dart +++ b/packages/ndk_flutter/test/wallet_input_classifier_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:ndk/entities.dart'; import 'package:ndk/domain_layer/usecases/nwc/consts/nwc_method.dart'; import 'package:ndk_flutter/ndk_flutter.dart'; @@ -8,12 +9,14 @@ void main() { authorizationEndpoint: Uri.parse('https://coinos.io/apps/new'), appName: 'NDK Demo', pubkey: 'generated-public-key', + state: '0123456789abcdef0123456789abcdef', ); expect(uri.origin, 'https://coinos.io'); expect(uri.path, '/apps/new'); expect(uri.queryParameters['name'], 'NDK Demo'); expect(uri.queryParameters['pubkey'], 'generated-public-key'); + expect(uri.queryParameters['state'], '0123456789abcdef0123456789abcdef'); }); test('builds generic NWC wallet-auth deep link', () { @@ -29,6 +32,7 @@ void main() { appPubkey: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', config: config, + state: '0123456789abcdef0123456789abcdef', ); expect(uri.scheme, 'nostr+walletauth'); @@ -40,6 +44,180 @@ void main() { expect(uri.queryParameters['name'], 'NDK Demo'); expect(uri.queryParameters['request_methods'], 'get_info pay_invoice'); expect(uri.queryParameters['return_to'], 'ndk://nwc'); + expect(uri.queryParameters['state'], '0123456789abcdef0123456789abcdef'); + }); + + test('builds generic callback URI handled by Primal and Alby Go', () { + const config = AlbyGoConnectConfig( + appName: 'NDK Demo', + appIconUrl: 'https://example.com/icon.png', + callback: 'ndk://nwc', + ); + + final uri = buildNwcCallbackUri(config: config); + + expect(uri.scheme, 'nostrnwc'); + expect(uri.host, 'connect'); + expect(uri.queryParameters['appname'], 'NDK Demo'); + expect(uri.queryParameters['appicon'], 'https://example.com/icon.png'); + expect(uri.queryParameters['callback'], 'ndk://nwc'); + }); + + test('builds Alby Go-specific wallet-auth deep link', () { + const config = AlbyGoConnectConfig( + appName: 'NDK Demo', + appIconUrl: 'https://example.com/icon.png', + callback: 'ndk://nwc', + ); + + final uri = buildNwcWalletAuthUri( + appPubkey: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + config: config, + state: '0123456789abcdef0123456789abcdef', + scheme: config.walletAuthScheme, + ); + + expect(uri.scheme, 'nostr+walletauth+alby'); + expect( + uri.queryParameters['request_methods'], + 'get_info get_balance get_budget make_invoice pay_invoice ' + 'lookup_invoice list_transactions', + ); + expect(config.nostrNwcScheme, 'nostrnwc+alby'); + expect(config.androidPackage, 'com.getalby.mobile'); + + final qrUri = buildNwcWalletAuthUri( + appPubkey: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + config: config, + state: '0123456789abcdef0123456789abcdef', + scheme: config.walletAuthScheme, + includeReturnTo: false, + ); + expect(qrUri.queryParameters.containsKey('return_to'), isFalse); + expect(qrUri.queryParameters['relay'], config.discoveryRelay); + expect(qrUri.queryParameters['state'], '0123456789abcdef0123456789abcdef'); + }); + + test('generates 128-bit lowercase hex wallet-auth state', () { + final first = generateNwcWalletAuthState(); + final second = generateNwcWalletAuthState(); + + expect(first, matches(RegExp(r'^[0-9a-f]{32}$'))); + expect(second, isNot(first)); + }); + + test('matches NWC-08 info by client pubkey and state', () { + const appPubkey = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + final event = Nip01Event( + pubKey: + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + kind: 13194, + tags: const [ + ['p', appPubkey], + ['state', '0123456789abcdef0123456789abcdef'], + ['relay', 'wss://wallet.example.com/CaseSensitive'], + ], + content: 'get_info get_balance', + ); + + expect( + matchesNwcWalletAuthInfoEvent( + event, + appPubkey: appPubkey, + state: '0123456789abcdef0123456789abcdef', + ), + isTrue, + ); + expect( + matchesNwcWalletAuthInfoEvent( + event, + appPubkey: appPubkey, + state: 'wrong-state', + ), + isFalse, + ); + expect( + matchesNwcWalletAuthInfoEvent( + event, + appPubkey: appPubkey, + state: '0123456789abcdef0123456789abcdef', + walletServicePubkey: + 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', + ), + isFalse, + ); + expect( + walletAuthConnectionRelay( + event, + fallbackRelay: 'wss://discovery.example.com', + ), + 'wss://wallet.example.com/CaseSensitive', + ); + + final eventWithoutState = Nip01Event( + pubKey: event.pubKey, + kind: event.kind, + tags: const [ + ['p', appPubkey], + ], + content: event.content, + ); + expect( + matchesNwcWalletAuthInfoEvent( + eventWithoutState, + appPubkey: appPubkey, + state: '0123456789abcdef0123456789abcdef', + ), + isTrue, + ); + + final untaggedLegacyEvent = Nip01Event( + pubKey: event.pubKey, + kind: event.kind, + tags: const [], + content: event.content, + ); + expect( + matchesNwcWalletAuthInfoEvent( + untaggedLegacyEvent, + appPubkey: appPubkey, + state: '0123456789abcdef0123456789abcdef', + walletServicePubkey: event.pubKey, + requireAppPubkeyTag: false, + ), + isTrue, + ); + expect( + matchesNwcWalletAuthInfoEvent( + untaggedLegacyEvent, + appPubkey: appPubkey, + state: '0123456789abcdef0123456789abcdef', + walletServicePubkey: + 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', + requireAppPubkeyTag: false, + ), + isFalse, + ); + + final eventWithoutRelay = Nip01Event( + pubKey: event.pubKey, + kind: event.kind, + tags: const [ + ['p', appPubkey], + ['state', '0123456789abcdef0123456789abcdef'], + ], + content: event.content, + ); + expect( + walletAuthConnectionRelay( + eventWithoutRelay, + fallbackRelay: 'wss://discovery.example.com', + ), + 'wss://discovery.example.com', + ); }); group('classifyWalletInput', () { diff --git a/packages/ndk_flutter/test/wallet_input_dialog_test.dart b/packages/ndk_flutter/test/wallet_input_dialog_test.dart new file mode 100644 index 000000000..39b2b9bb4 --- /dev/null +++ b/packages/ndk_flutter/test/wallet_input_dialog_test.dart @@ -0,0 +1,350 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ndk/ndk.dart'; +import 'package:ndk_flutter/l10n/app_localizations.dart'; +import 'package:ndk_flutter/ndk_flutter.dart'; + +const offer = + 'lno1pqqq5xj5wajkcan9gdshx6pq23jhxarfdenjqstyv3ex2umnzcss80xkrjkyrjk43u5dgu8f6a450fg2cnjtg7lhg76c3gtk5gdhshns'; + +class UnusedNdk implements Ndk { + @override + dynamic noSuchMethod(Invocation invocation) => + throw StateError('Unexpected NDK access'); +} + +class RecordingAuthCoordinator extends NwcWalletAuthCoordinator { + Uri? endpoint; + String? app; + String? relay; + String? callbackUrl; + String? provider; + String? servicePubkey; + NdkFlutter? discoveryNdkFlutter; + bool? allowUntaggedInfoEvent; + Map? query; + + @override + Future connectWebWalletAuth( + BuildContext context, { + required Uri authorizationEndpoint, + required String appName, + required String discoveryRelay, + required String callback, + required String walletName, + String? providerId, + String? walletServicePubkey, + NdkFlutter? waitForDiscoveryNdkFlutter, + bool allowUntaggedInfoEvent = false, + Map additionalQueryParameters = const {}, + }) async { + endpoint = authorizationEndpoint; + app = appName; + relay = discoveryRelay; + callbackUrl = callback; + provider = providerId; + servicePubkey = walletServicePubkey; + discoveryNdkFlutter = waitForDiscoveryNdkFlutter; + query = additionalQueryParameters; + this.allowUntaggedInfoEvent = allowUntaggedInfoEvent; + } +} + +class FakeCamera extends StatefulWidget { + final ValueChanged onScan; + final VoidCallback onDispose; + const FakeCamera({super.key, required this.onScan, required this.onDispose}); + @override + State createState() => _FakeCameraState(); +} + +class _FakeCameraState extends State { + @override + void dispose() { + widget.onDispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => TextButton( + onPressed: () => widget.onScan(offer), + child: const Text('Decode QR'), + ); +} + +Future openWalletInput( + WidgetTester tester, { + WalletQrScannerBuilder? camera, + NwcWalletAuthCoordinator? coordinator, + List? options, +}) async { + tester.view.physicalSize = const Size(1200, 1200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + late AppLocalizations l10n; + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) { + l10n = AppLocalizations.of(context)!; + return TextButton( + onPressed: () => showAddWalletTypeDialog( + context, + NdkFlutter(ndk: UnusedNdk()), + walletQrScannerBuilder: camera, + nwcWalletAuthCoordinator: coordinator, + nwcConnectionOptions: options, + ), + child: const Text('Add wallet'), + ); + }, + ), + ), + ), + ); + await tester.tap(find.text('Add wallet')); + await tester.pumpAndSettle(); + return l10n; +} + +void main() { + testWidgets('camera-free default supports manual BOLT12 confirmation', ( + tester, + ) async { + final l10n = await openWalletInput(tester); + expect(find.text(l10n.cameraNotAvailable), findsOneWidget); + await tester.tap(find.text(l10n.pasteOrEnter)); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).last, offer); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.reviewWallet)); + await tester.pumpAndSettle(); + expect(find.text(l10n.confirm), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('camera decoding reaches confirmation and releases camera', ( + tester, + ) async { + var disposals = 0; + final l10n = await openWalletInput( + tester, + camera: (_, onScan, onError) => + FakeCamera(onScan: onScan, onDispose: () => disposals++), + ); + tester.widget(find.byType(FakeCamera)).onScan(offer); + await tester.pumpAndSettle(); + expect(disposals, 1); + expect(find.text(l10n.confirm), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'nested manual dialog disposes camera and cancellation resumes it', + (tester) async { + var disposals = 0; + final l10n = await openWalletInput( + tester, + camera: (_, onScan, onError) => + FakeCamera(onScan: onScan, onDispose: () => disposals++), + ); + await tester.tap(find.text(l10n.pasteOrEnter)); + await tester.pumpAndSettle(); + expect(disposals, 1); + expect(find.byType(FakeCamera), findsNothing); + await tester.tap(find.text(l10n.cancel).last); + await tester.pumpAndSettle(); + expect(find.byType(FakeCamera), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('wallet chooser and nested NWC scanner share host camera', ( + tester, + ) async { + var disposals = 0; + final l10n = await openWalletInput( + tester, + camera: (_, onScan, onError) => + FakeCamera(onScan: onScan, onDispose: () => disposals++), + ); + await tester.tap(find.text(l10n.chooseWallet)); + await tester.pumpAndSettle(); + expect(disposals, 1); + expect(find.text('Coinos'), findsOneWidget); + await tester.tap(find.text('NWC')); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip(l10n.scanWalletQrCode).last); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byType(FakeCamera), findsOneWidget); + tester.widget(find.byType(FakeCamera)).onScan(offer); + await tester.pumpAndSettle(); + expect(disposals, 2); + expect(find.byType(FakeCamera), findsNothing); + expect( + tester.widget(find.byType(TextField).last).controller!.text, + offer, + ); + expect(tester.takeException(), isNull); + }); + testWidgets('camera failure stays visible and manual input remains usable', ( + tester, + ) async { + final l10n = await openWalletInput( + tester, + camera: (_, onScan, onError) => TextButton( + onPressed: () => onError(StateError('Camera denied')), + child: const Text('Fail camera'), + ), + ); + await tester.tap(find.text('Fail camera')); + await tester.pumpAndSettle(); + expect(find.text('Bad state: Camera denied'), findsOneWidget); + await tester.tap(find.text(l10n.pasteOrEnter)); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsWidgets); + expect(tester.takeException(), isNull); + }); + + testWidgets('connection status releases camera and idle resumes it', ( + tester, + ) async { + final coordinator = NwcWalletAuthCoordinator(); + var disposals = 0; + final l10n = await openWalletInput( + tester, + coordinator: coordinator, + camera: (_, onScan, onError) => + FakeCamera(onScan: onScan, onDispose: () => disposals++), + ); + coordinator.connectionState.value = + const WalletConnectionState.awaitingReturn('Coinos'); + await tester.pump(); + expect(disposals, 1); + expect(find.byType(FakeCamera), findsNothing); + expect(find.widgetWithText(OutlinedButton, l10n.cancel), findsOneWidget); + await tester.tap(find.widgetWithText(OutlinedButton, l10n.cancel)); + await tester.pumpAndSettle(); + expect(coordinator.connectionState.value.phase, WalletConnectionPhase.idle); + expect(find.byType(FakeCamera), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('connected status closes scanner with nested chooser open', ( + tester, + ) async { + final coordinator = NwcWalletAuthCoordinator(); + final l10n = await openWalletInput( + tester, + coordinator: coordinator, + options: [ + NwcConnectionOption( + id: 'custom', + label: 'Connected wallet', + connect: (_, _, _) async { + coordinator.connectionState.value = + const WalletConnectionState.connected('Coinos'); + }, + ), + ], + ); + await tester.tap(find.text(l10n.chooseWallet)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Connected wallet')); + await tester.pump(); + expect(find.byIcon(Icons.check_circle), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 1200)); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.check_circle), findsNothing); + expect(find.text(l10n.walletConnectionConnected('Coinos')), findsNothing); + expect(find.text('Add wallet'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('provider overrides replace presets in shared chooser', ( + tester, + ) async { + final l10n = await openWalletInput( + tester, + options: [ + NwcConnectionOption( + id: 'custom', + label: 'My wallet', + connect: (_, _, _) async {}, + ), + ], + ); + await tester.tap(find.text(l10n.chooseWallet)); + await tester.pumpAndSettle(); + expect(find.text('Coinos'), findsNothing); + expect(find.text('My wallet'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('desktop wallet chooser enables Alby Go QR connection', ( + tester, + ) async { + final l10n = await openWalletInput(tester); + await tester.tap(find.text(l10n.chooseWallet)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.albyWalletOption)); + await tester.pumpAndSettle(); + + final albyGoTile = tester.widget( + find.widgetWithText(ListTile, l10n.albyGoOption), + ); + expect(albyGoTile.enabled, isTrue); + await tester.tap(find.widgetWithText(ListTile, l10n.albyGoOption)); + await tester.pumpAndSettle(); + expect(find.text(l10n.walletConnectionFinishIn('Alby Go')), findsOneWidget); + expect(find.text(l10n.albyGoQrScanInstructions), findsOneWidget); + expect(find.text(l10n.confirm), findsNothing); + await tester.tap(find.text(l10n.cancel).last); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + testWidgets('web presets preserve host callback and provider discovery', ( + tester, + ) async { + const config = AlbyGoConnectConfig( + appName: 'BitBlik', + appIconUrl: 'https://example.com/icon.png', + callback: 'bitblik://nwc-callback', + discoveryRelay: 'wss://example.com', + ); + final coordinator = RecordingAuthCoordinator(); + final options = defaultNwcConnectionOptions(config: config); + await tester.pumpWidget(const MaterialApp(home: Scaffold())); + final context = tester.element(find.byType(Scaffold)); + final ndkFlutter = NdkFlutter(ndk: UnusedNdk()); + + await options.first.connect(context, ndkFlutter, coordinator); + expect(coordinator.endpoint.toString(), 'https://my.albyhub.com/apps/new'); + expect(coordinator.app, config.appName); + expect(coordinator.callbackUrl, config.callback); + expect(coordinator.relay, config.discoveryRelay); + expect(coordinator.query, {'return_to': config.callback}); + expect(coordinator.provider, 'alby'); + expect(coordinator.discoveryNdkFlutter, same(ndkFlutter)); + expect(coordinator.allowUntaggedInfoEvent, isFalse); + + await options.last.connect(context, ndkFlutter, coordinator); + expect(coordinator.endpoint.toString(), 'https://coinos.io/apps/new'); + expect(coordinator.app, config.appName); + expect(coordinator.callbackUrl, config.callback); + expect(coordinator.relay, 'wss://relay.coinos.io'); + expect( + coordinator.servicePubkey, + 'ba80990666ef0b6f4ba5059347beb13242921e54669e680064ca755256a1e3a6', + ); + expect(coordinator.provider, 'coinos'); + expect(coordinator.discoveryNdkFlutter, same(ndkFlutter)); + expect(coordinator.allowUntaggedInfoEvent, isTrue); + }); +} diff --git a/packages/ndk_flutter/test/wallet_scan_flow_test.dart b/packages/ndk_flutter/test/wallet_scan_flow_test.dart new file mode 100644 index 000000000..aa034fe10 --- /dev/null +++ b/packages/ndk_flutter/test/wallet_scan_flow_test.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ndk/ndk.dart'; +import 'package:ndk_flutter/l10n/app_localizations.dart'; +import 'package:ndk_flutter/ndk_flutter.dart'; + +const _offer = + 'lno1pqqq5xj5wajkcan9gdshx6pq23jhxarfdenjqstyv3ex2umnzcss80xkrjkyrjk43u5dgu8f6a450fg2cnjtg7lhg76c3gtk5gdhshns'; + +// Preparing a direct offer must not add a wallet or access the network. +class _UnusedNdk implements Ndk { + @override + dynamic noSuchMethod(Invocation invocation) => + throw StateError('Unexpected NDK access: ${invocation.memberName}'); +} + +Future _openScanner( + WidgetTester tester, + WalletInputScanner scanner, +) async { + late AppLocalizations l10n; + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) { + l10n = AppLocalizations.of(context)!; + return TextButton( + onPressed: () => showAddWalletTypeDialog( + context, + NdkFlutter(ndk: _UnusedNdk()), + walletInputScanner: scanner, + ), + child: const Text('Open scanner'), + ); + }, + ), + ), + ), + ); + await tester.tap(find.text('Open scanner')); + await tester.pumpAndSettle(); + return l10n; +} + +void main() { + testWidgets('unsupported scan closes obsolete flow and shows error', ( + tester, + ) async { + final l10n = await _openScanner(tester, (_, _) async { + return const WalletInputScanResult.value('unrecognized QR content'); + }); + expect(find.text(l10n.unsupportedWalletInput), findsOneWidget); + expect(find.text(l10n.addWalletTitle), findsNothing); + }); + + testWidgets('scanner exception closes obsolete flow and shows error', ( + tester, + ) async { + final l10n = await _openScanner(tester, (_, _) async { + throw Exception('Camera unavailable'); + }); + expect(find.text('Exception: Camera unavailable'), findsOneWidget); + expect(find.text(l10n.addWalletTitle), findsNothing); + }); + + testWidgets('invalid offer closes obsolete flow with validation error', ( + tester, + ) async { + final l10n = await _openScanner(tester, (_, _) async { + return const WalletInputScanResult.value('lno1invalid'); + }); + expect(find.textContaining('Invalid'), findsOneWidget); + expect(find.text(l10n.addWalletTitle), findsNothing); + }); + + for (final value in [_offer, 'bitcoin?lno=$_offer']) { + testWidgets('valid scanned ${value == _offer ? 'offer' : 'shorthand'} ' + 'reaches confirmation without adding wallet', (tester) async { + final l10n = await _openScanner(tester, (_, _) async { + return WalletInputScanResult.value(value); + }); + expect(find.text(l10n.confirm), findsOneWidget); + }); + } +} diff --git a/packages/sample-app/.gitignore b/packages/sample-app/.gitignore index 29a3a5017..4112b3935 100644 --- a/packages/sample-app/.gitignore +++ b/packages/sample-app/.gitignore @@ -41,3 +41,4 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release +thresholds_new.pnm diff --git a/packages/sample-app/lib/linux_qr_scanner.dart b/packages/sample-app/lib/linux_qr_scanner.dart new file mode 100644 index 000000000..c1c68cd81 --- /dev/null +++ b/packages/sample-app/lib/linux_qr_scanner.dart @@ -0,0 +1,212 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'package:flutter_webrtc_zxing/flutter_webrtc_zxing.dart' as zxing; +import 'package:image/image.dart' as image; + +/// Decodes original camera pixels without the ReaderWidget's 768px resize and +/// central crop, which discard detail and finder patterns in dense offer QRs. +/// Called through compute so image conversion and detection stay off the UI. +String? decodeLinuxQrFrame(Uint8List bytes) { + final frame = image.decodeImage(bytes); + if (frame == null) return null; + final result = zxing.zx.readBarcode( + frame.getBytes(order: image.ChannelOrder.rgb), + zxing.DecodeParams( + width: frame.width, + height: frame.height, + imageFormat: zxing.ImageFormat.rgb, + format: zxing.Format.qrCode, + tryHarder: true, + tryRotate: true, + tryInverted: true, + tryDownscale: true, + ), + ); + return result.isValid ? result.text : null; +} + +Future decodeWebQrFrame(Uint8List bytes) async { + final result = await zxing.zx.processWebRtcFrame( + bytes, + zxing.DecodeParams( + format: zxing.Format.qrCode, + tryHarder: true, + tryRotate: true, + tryInverted: true, + tryDownscale: true, + ), + cropPercent: 0, + ); + return result.isValid ? result.text : null; +} + +/// Linux/Chrome camera preview and sequential full-resolution QR decoding. +class FullFrameQrScanner extends StatefulWidget { + final ValueChanged onScan; + final ValueChanged onError; + + const FullFrameQrScanner({ + super.key, + required this.onScan, + required this.onError, + }); + + @override + State createState() => _FullFrameQrScannerState(); +} + +class _FullFrameQrScannerState extends State + with WidgetsBindingObserver { + RTCVideoRenderer? _renderer; + Future? _session; + int _generation = 0; + int _decodeAttempts = 0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _restart(); + } + + bool _isCurrent(int generation) => mounted && generation == _generation; + + void _restart() { + final generation = ++_generation; + final previous = _session; + _session = () async { + // Wait for capture/decoding and camera release before opening again. + await previous; + if (_isCurrent(generation)) await _runCamera(generation); + }(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _restart(); + } else if (state == AppLifecycleState.paused || + state == AppLifecycleState.hidden || + state == AppLifecycleState.detached) { + ++_generation; + } + } + + Future _runCamera(int generation) async { + final renderer = RTCVideoRenderer(); + MediaStream? stream; + try { + if (kIsWeb) { + await zxing.zx.startCameraProcessing(); + if (kDebugMode) debugPrint('[web-qr] WASM decoder ready'); + } else { + await WebRTC.initialize(); + } + if (!_isCurrent(generation)) return; + await renderer.initialize(); + if (!_isCurrent(generation)) return; + stream = await navigator.mediaDevices.getUserMedia({ + 'audio': false, + 'video': { + 'width': {'ideal': 1920}, + 'height': {'ideal': 1080}, + 'frameRate': {'ideal': 15}, + 'facingMode': 'environment', + }, + }); + if (!_isCurrent(generation)) return; + final tracks = stream.getVideoTracks(); + if (tracks.isEmpty) throw StateError('Camera has no video track'); + final firstFrame = Completer(); + renderer.onFirstFrameRendered = () { + if (!firstFrame.isCompleted) firstFrame.complete(); + }; + renderer.srcObject = stream; + setState(() => _renderer = renderer); + if (kIsWeb) { + // Chrome's WebRTC renderer does not reliably invoke + // onFirstFrameRendered. Wait for dimensions, but let captureFrame + // proceed even when they remain unavailable. + for (var attempt = 0; + attempt < 20 && renderer.videoWidth == 0 && _isCurrent(generation); + attempt++) { + await Future.delayed(const Duration(milliseconds: 100)); + } + } else { + await firstFrame.future.timeout(const Duration(seconds: 10)); + } + if (!_isCurrent(generation)) return; + if (kDebugMode) { + debugPrint('QR camera: ${renderer.videoWidth}x' + '${renderer.videoHeight}; full-frame decoding'); + } + + while (_isCurrent(generation)) { + final frame = await tracks.first.captureFrame(); + if (!_isCurrent(generation)) break; + final bytes = frame.asUint8List(); + _decodeAttempts++; + if (kDebugMode && (_decodeAttempts == 1 || _decodeAttempts % 10 == 0)) { + debugPrint('[${kIsWeb ? 'web' : 'linux'}-qr] decode attempt ' + '$_decodeAttempts; frame=${bytes.length} bytes'); + } + final value = kIsWeb + ? await decodeWebQrFrame(bytes) + : await compute(decodeLinuxQrFrame, bytes); + if (!_isCurrent(generation)) break; + if (value != null && value.trim().isNotEmpty) { + if (kDebugMode) { + // QR payloads can contain wallet credentials; never log contents. + debugPrint('[qr] decoded ${value.length} characters; ' + 'returning scan result'); + } + widget.onScan(value); + break; + } + // No overlapping captures or queued decodes on slower machines. + await Future.delayed(const Duration(milliseconds: 250)); + } + } catch (error) { + if (_isCurrent(generation)) { + widget.onError(error is Exception ? error : Exception('$error')); + } + } finally { + if (identical(_renderer, renderer)) { + if (mounted) { + setState(() => _renderer = null); + } else { + _renderer = null; + } + } + if (stream != null) { + for (final track in stream.getTracks()) { + await track.stop(); + } + await stream.dispose(); + } + await renderer.dispose(); + if (kIsWeb) zxing.zx.stopCameraProcessing(); + } + } + + @override + void dispose() { + ++_generation; + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final renderer = _renderer; + return renderer == null + ? const ColoredBox(color: Colors.black) + : RTCVideoView( + renderer, + objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, + ); + } +} diff --git a/packages/sample-app/lib/nwc_qr_scanner.dart b/packages/sample-app/lib/nwc_qr_scanner.dart index 6f60f417e..d2787c5c6 100644 --- a/packages/sample-app/lib/nwc_qr_scanner.dart +++ b/packages/sample-app/lib/nwc_qr_scanner.dart @@ -1,1794 +1,34 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:flutter_webrtc_zxing/flutter_webrtc_zxing.dart' as webrtc_zxing; import 'package:mobile_scanner/mobile_scanner.dart'; -import 'package:ndk_flutter/ndk_flutter.dart'; -import 'package:ndk_flutter/l10n/app_localizations.dart' as ndk_l10n; -Future scanWalletInput( +import 'linux_qr_scanner.dart'; + +/// Camera adapter only. Wallet input UI and navigation belong to ndk_flutter. +Widget buildWalletQrScanner( BuildContext context, - WalletInputScannerConfiguration configuration, + ValueChanged onScan, + ValueChanged onError, ) { - return showDialog( - context: context, - builder: (context) => _WalletQrScannerDialog( - configuration: configuration, - ), - ); -} - -class _WalletQrScannerDialog extends StatefulWidget { - final WalletInputScannerConfiguration configuration; - - const _WalletQrScannerDialog({required this.configuration}); - - @override - State<_WalletQrScannerDialog> createState() => _WalletQrScannerDialogState(); -} - -class _WalletQrScannerDialogState extends State<_WalletQrScannerDialog> { - MobileScannerController? _scannerController; - bool _hasScanned = false; - bool _cameraPaused = false; - String? _errorMessage; - bool _closingAfterSuccess = false; - - bool get _usesMobileScanner => - kIsWeb || - defaultTargetPlatform == TargetPlatform.android || - defaultTargetPlatform == TargetPlatform.iOS; - - bool get _usesWebRtcScanner => - !kIsWeb && defaultTargetPlatform == TargetPlatform.linux; - - bool get _hasCamera => _usesMobileScanner || _usesWebRtcScanner; - - @override - void initState() { - super.initState(); - if (_usesMobileScanner) { - _scannerController = MobileScannerController( - detectionSpeed: DetectionSpeed.normal, - facing: CameraFacing.back, - ); - } - widget.configuration.connectionState.addListener(_onConnectionStateChanged); - if (widget.configuration.openWalletChooserInitially || - widget.configuration.openCashuMintChooserInitially) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _chooseWallet(); - }); - } - } - - @override - void dispose() { - widget.configuration.connectionState.removeListener( - _onConnectionStateChanged, - ); - _scannerController?.dispose(); - super.dispose(); - } - - void _onConnectionStateChanged() { - if (!mounted) return; - final state = widget.configuration.connectionState.value; - setState(() {}); - if (state.phase == WalletConnectionPhase.connected && - !_closingAfterSuccess) { - _closingAfterSuccess = true; - Future.delayed(const Duration(milliseconds: 1100), () { - if (!mounted) return; - Navigator.of(context).pop( - const WalletInputScanResult.connectionStarted(), - ); - }); - } - } - - void _onBarcodeDetected(BarcodeCapture capture) { - if (_hasScanned) return; - - for (final barcode in capture.barcodes) { - final rawValue = barcode.rawValue?.trim(); - if (rawValue == null || rawValue.isEmpty) continue; - - setState(() => _hasScanned = true); - Navigator.of(context).pop(WalletInputScanResult.value(rawValue)); - return; - } - } - - void _onWebRtcBarcodeDetected(webrtc_zxing.Code code) { - if (_hasScanned) return; - final rawValue = code.text?.trim(); - if (rawValue == null || rawValue.isEmpty) return; - - setState(() => _hasScanned = true); - Navigator.of(context).pop(WalletInputScanResult.value(rawValue)); - } - - void _onWebRtcScannerCreated(Object? _, Exception? error) { - if (!mounted || error == null) return; - setState(() => _errorMessage = error.toString()); - } - - Future _openManualInput() async { - await _showManualInput(); - } - - Future _showManualInput({ - String initialValue = '', - bool nwcOnly = false, - }) async { - if (_usesWebRtcScanner && mounted) { - setState(() => _cameraPaused = true); - } - await _scannerController?.stop(); - if (!mounted) return; - final result = await showDialog<_ManualWalletInputResult>( - context: context, - builder: (_) => _ManualWalletInputDialog( - initialValue: initialValue, - supportedInputDescription: - widget.configuration.supportedInputDescription, - nwcOnly: nwcOnly, - ), - ); - - if (!mounted) return; - if (result == null) { - if (_usesWebRtcScanner) setState(() => _cameraPaused = false); - await _scannerController?.start(); - return; - } - if (result.connectionStarted) { - Navigator.of(context).pop( - const WalletInputScanResult.connectionStarted(), - ); - return; - } - final value = result.value?.trim(); - if (value == null || value.isEmpty) return; - Navigator.of( - context, - ).pop(WalletInputScanResult.value(value, manuallyEntered: true)); - } - - Future _chooseWallet() async { - if (_usesWebRtcScanner && mounted) { - setState(() => _cameraPaused = true); - } - await _scannerController?.stop(); - if (!mounted) return; - final result = await showDialog( - context: context, - builder: (_) => _WalletChooserDialog( - configuration: widget.configuration, - ), - ); - if (!mounted) return; - if (result == null) { - if (_usesWebRtcScanner && mounted) { - setState(() => _cameraPaused = false); - } - await _scannerController?.start(); - return; - } - if (!result.connectionStarted) Navigator.of(context).pop(result); - } - - Future _retryConnection() async { - await widget.configuration.retryPendingConnection(); - } - - Future _chooseOtherWallet() async { - widget.configuration.cancelPendingConnection(); - await _chooseWallet(); - } - - @override - Widget build(BuildContext context) { - final l10n = ndk_l10n.AppLocalizations.of(context)!; - final hasCamera = _hasCamera; - - final connectionState = widget.configuration.connectionState.value; - return Dialog( - backgroundColor: Colors.black, - child: Stack( - children: [ - SizedBox( - width: 400, - height: hasCamera ? 680 : 520, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const SizedBox(width: 48), - Expanded( - child: Text( - l10n.scanWalletQrCode, - style: const TextStyle( - color: Colors.white, fontSize: 18), - textAlign: TextAlign.center, - ), - ), - IconButton( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.close, color: Colors.white), - ), - ], - ), - ), - if (hasCamera) - Expanded( - child: Stack( - children: [ - if (_cameraPaused) - const ColoredBox(color: Colors.black) - else if (_usesWebRtcScanner) - webrtc_zxing.ReaderWidget( - codeFormat: webrtc_zxing.Format.qrCode, - cropPercent: 0.7, - scanDelay: const Duration(milliseconds: 250), - scanDelaySuccess: const Duration(milliseconds: 250), - showGallery: false, - showToggleCamera: false, - showScannerOverlay: false, - onScan: _onWebRtcBarcodeDetected, - onRendererCreated: _onWebRtcScannerCreated, - ) - else - MobileScanner( - controller: _scannerController!, - onDetect: _onBarcodeDetected, - errorBuilder: _mobileScannerError, - ), - Center( - child: Container( - width: 250, - height: 250, - decoration: BoxDecoration( - border: Border.all(color: Colors.white, width: 2), - borderRadius: BorderRadius.circular(12), - ), - ), - ), - if (_errorMessage != null) _buildErrorMessage(), - if (_hasScanned) - Container( - color: Colors.black.withValues(alpha: 0.7), - child: const Center( - child: CircularProgressIndicator( - color: Colors.white), - ), - ), - ], - ), - ) - else - Expanded( - child: Stack( - children: [ - Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Text( - l10n.cameraNotAvailable, - style: const TextStyle(color: Colors.white70), - textAlign: TextAlign.center, - ), - ), - ), - if (_errorMessage != null) _buildErrorMessage(), - ], - ), - ), - Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - widget.configuration.supportedInputDescription, - style: const TextStyle(color: Colors.white70), - textAlign: TextAlign.center, - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: _hasScanned ? null : _openManualInput, - icon: const Icon(Icons.paste), - label: FittedBox( - fit: BoxFit.scaleDown, - child: Text(l10n.pasteOrEnter), - ), - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of( - context, - ).colorScheme.primary, - foregroundColor: Colors.white, - minimumSize: const Size.fromHeight(48), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: OutlinedButton.icon( - onPressed: _hasScanned ? null : _chooseWallet, - icon: const Icon(Icons.account_balance_wallet), - label: Text(l10n.chooseWallet), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: const BorderSide(color: Colors.white38), - minimumSize: const Size.fromHeight(48), - ), - ), - ), - ], - ), - ], - ), - ), - ], - ), - ), - if (connectionState.phase != WalletConnectionPhase.idle) - Positioned.fill( - child: _ConnectionStatusOverlay( - state: connectionState, - onRetry: _retryConnection, - onChooseOtherWallet: _chooseOtherWallet, - ), - ), - ], - ), - ); - } - - Widget _mobileScannerError( - BuildContext context, - MobileScannerException error, - ) { - return ColoredBox( - color: Colors.black, - child: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text( - error.toString(), - style: const TextStyle(color: Colors.white), - textAlign: TextAlign.center, - ), - ), - ), - ); - } - - Widget _buildErrorMessage() { - return Positioned( - top: 20, - left: 20, - right: 20, - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.red.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - _errorMessage!, - style: const TextStyle(color: Colors.white), - textAlign: TextAlign.center, - ), - ), - ); - } -} - -class _ConnectionStatusOverlay extends StatelessWidget { - final WalletConnectionState state; - final Future Function() onRetry; - final Future Function() onChooseOtherWallet; - - const _ConnectionStatusOverlay({ - required this.state, - required this.onRetry, - required this.onChooseOtherWallet, - }); - - @override - Widget build(BuildContext context) { - final l10n = ndk_l10n.AppLocalizations.of(context)!; - final failed = state.phase == WalletConnectionPhase.failed; - final connected = state.phase == WalletConnectionPhase.connected; - final walletName = state.walletName ?? l10n.unknownWalletType; - - return ColoredBox( - color: Colors.black.withValues(alpha: 0.94), - child: Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (connected) - TweenAnimationBuilder( - tween: Tween(begin: 0.4, end: 1), - duration: const Duration(milliseconds: 420), - curve: Curves.easeOutBack, - builder: (context, scale, child) => Transform.scale( - scale: scale, - child: child, - ), - child: const Icon( - Icons.check_circle, - color: Colors.greenAccent, - size: 88, - ), - ) - else if (failed) - const Icon(Icons.error_outline, - color: Colors.redAccent, size: 72) - else - const CircularProgressIndicator(color: Colors.white), - const SizedBox(height: 24), - Text( - connected - ? l10n.walletConnectionConnected(walletName) - : failed - ? l10n.walletConnectionFailed(walletName) - : state.phase == WalletConnectionPhase.awaitingReturn - ? l10n.walletConnectionFinishIn(walletName) - : l10n.walletConnectionConnecting(walletName), - style: const TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.w600, - ), - textAlign: TextAlign.center, - ), - if (failed) ...[ - const SizedBox(height: 12), - if (state.error case final error?) - Text( - error, - style: const TextStyle(color: Colors.white70), - textAlign: TextAlign.center, - ), - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: onRetry, - child: Text(l10n.retry), - ), - ), - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: OutlinedButton( - onPressed: onChooseOtherWallet, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: const BorderSide(color: Colors.white38), - ), - child: Text(l10n.chooseAnotherWallet), - ), - ), - ], - ], - ), - ), - ), - ); - } -} - -class _ManualWalletInputDialog extends StatefulWidget { - final String initialValue; - final String supportedInputDescription; - final bool nwcOnly; - final Future Function()? connectWalletApp; - - const _ManualWalletInputDialog({ - required this.initialValue, - required this.supportedInputDescription, - required this.nwcOnly, - this.connectWalletApp, - }); - - @override - State<_ManualWalletInputDialog> createState() => - _ManualWalletInputDialogState(); -} - -class _ManualWalletInputDialogState extends State<_ManualWalletInputDialog> { - late final TextEditingController _controller; - WalletInputKind? _kind; - bool _isLaunchingWallet = false; - bool _isScanningQr = false; - - bool get _isValid => - _kind != null && (!widget.nwcOnly || _kind == WalletInputKind.nwc); - - @override - void initState() { - super.initState(); - _controller = TextEditingController(text: widget.initialValue.trim()); - _kind = classifyWalletInput(_controller.text); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - String _kindLabel( - ndk_l10n.AppLocalizations l10n, - WalletInputKind kind, - ) { - return switch (kind) { - WalletInputKind.nwc => l10n.nwcWalletTypeTitle, - WalletInputKind.bolt12 => l10n.bolt12WalletTypeTitle, - WalletInputKind.lightningAddress => l10n.lightningAddressInputType, - WalletInputKind.cashuMint => l10n.cashuWalletTypeTitle, - WalletInputKind.lnBits => l10n.lnbitsWalletOption, - }; - } - - Future _connectWalletApp() async { - final connect = widget.connectWalletApp; - if (connect == null || _isLaunchingWallet) return; - setState(() => _isLaunchingWallet = true); - try { - await connect(); - if (!mounted) return; - Navigator.of(context).pop( - const _ManualWalletInputResult.connectionStarted(), - ); - } finally { - if (mounted) setState(() => _isLaunchingWallet = false); - } - } - - Future _scanQrCode() async { - if (_isScanningQr) return; - setState(() => _isScanningQr = true); - try { - final value = await showDialog( - context: context, - builder: (_) => const _QrCodeScannerDialog(), - ); - if (!mounted || value == null) return; - _controller.text = value; - _controller.selection = TextSelection.collapsed(offset: value.length); - setState(() => _kind = classifyWalletInput(value)); - } finally { - if (mounted) setState(() => _isScanningQr = false); - } - } - - Future _pasteInput() async { - final clipboardData = await Clipboard.getData(Clipboard.kTextPlain); - if (!mounted) return; - final value = clipboardData?.text?.trim() ?? ''; - _controller.text = value; - _controller.selection = TextSelection.collapsed(offset: value.length); - setState(() => _kind = classifyWalletInput(value)); - } - - void _clearInput() { - _controller.clear(); - setState(() => _kind = null); - } - - @override - Widget build(BuildContext context) { - final l10n = ndk_l10n.AppLocalizations.of(context)!; - final kind = _kind; - final hasInput = _controller.text.trim().isNotEmpty; - final isNwcInput = kind == WalletInputKind.nwc; - - return AlertDialog( - title: Text(widget.nwcOnly ? l10n.manualNwcConnection : l10n.walletInput), - content: SizedBox( - width: 480, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextField( - controller: _controller, - autofocus: true, - autocorrect: false, - obscureText: isNwcInput, - enableSuggestions: !isNwcInput, - keyboardType: TextInputType.url, - minLines: isNwcInput ? 1 : 2, - maxLines: isNwcInput ? 1 : 4, - onChanged: (value) { - setState(() => _kind = classifyWalletInput(value)); - }, - decoration: InputDecoration( - border: const OutlineInputBorder(), - hintText: widget.nwcOnly - ? l10n.nwcConnectionUriHint - : widget.supportedInputDescription, - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: _pasteInput, - tooltip: l10n.paste, - icon: const Icon(Icons.content_paste_outlined), - ), - if (widget.nwcOnly) - IconButton( - onPressed: _isScanningQr ? null : _scanQrCode, - tooltip: l10n.scanWalletQrCode, - icon: _isScanningQr - ? const SizedBox.square( - dimension: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Icon(Icons.qr_code_scanner), - ), - if (hasInput) - IconButton( - onPressed: _clearInput, - tooltip: l10n.clearInput, - icon: const Icon(Icons.clear), - ), - ], - ), - ), - ), - if (widget.nwcOnly && widget.connectWalletApp != null) ...[ - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: _isLaunchingWallet ? null : _connectWalletApp, - icon: _isLaunchingWallet - ? const SizedBox.square( - dimension: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.account_balance_wallet_outlined), - label: Text(l10n.oneClickConnect), - ), - ), - ], - if (hasInput) ...[ - const SizedBox(height: 12), - Row( - children: [ - Icon( - _isValid ? Icons.check_circle : Icons.error_outline, - size: 18, - color: _isValid - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.error, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - _isValid - ? '${l10n.detected}: ${_kindLabel(l10n, kind!)}' - : l10n.unsupportedWalletInput, - ), - ), - ], - ), - ], - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(l10n.cancel), - ), - FilledButton( - onPressed: _isValid - ? () => Navigator.of(context).pop( - _ManualWalletInputResult.value(_controller.text.trim()), - ) - : null, - child: Text(l10n.reviewWallet), - ), - ], - ); - } -} - -class _ManualWalletInputResult { - final String? value; - final bool connectionStarted; - - const _ManualWalletInputResult.value(this.value) : connectionStarted = false; - - const _ManualWalletInputResult.connectionStarted() - : value = null, - connectionStarted = true; -} - -class _QrCodeScannerDialog extends StatefulWidget { - const _QrCodeScannerDialog(); - - @override - State<_QrCodeScannerDialog> createState() => _QrCodeScannerDialogState(); -} - -class _QrCodeScannerDialogState extends State<_QrCodeScannerDialog> { - bool _hasScanned = false; - String? _error; - - bool get _usesMobileScanner => - kIsWeb || - defaultTargetPlatform == TargetPlatform.android || - defaultTargetPlatform == TargetPlatform.iOS; - - bool get _usesWebRtcScanner => - !kIsWeb && defaultTargetPlatform == TargetPlatform.linux; - - void _complete(String? value) { - final normalized = value?.trim(); - if (_hasScanned || normalized == null || normalized.isEmpty) return; - _hasScanned = true; - Navigator.of(context).pop(normalized); - } - - void _onMobileScan(BarcodeCapture capture) { - for (final barcode in capture.barcodes) { - final value = barcode.rawValue; - if (value?.trim().isNotEmpty == true) { - _complete(value); - return; + if (kIsWeb || defaultTargetPlatform == TargetPlatform.linux) { + return FullFrameQrScanner(onScan: onScan, onError: onError); + } + return MobileScanner( + onDetect: (capture) { + for (final barcode in capture.barcodes) { + final value = barcode.rawValue?.trim(); + if (value != null && value.isNotEmpty) { + onScan(value); + break; + } } - } - } - - void _onWebRtcScannerCreated(Object? _, Exception? error) { - if (!mounted || error == null) return; - setState(() => _error = error.toString()); - } - - @override - Widget build(BuildContext context) { - final l10n = ndk_l10n.AppLocalizations.of(context)!; - return Dialog( - backgroundColor: Colors.black, - child: SizedBox( - width: 400, - height: 560, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Expanded( - child: Text( - l10n.scanWalletQrCode, - style: const TextStyle(color: Colors.white, fontSize: 18), - textAlign: TextAlign.center, - ), - ), - IconButton( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.close, color: Colors.white), - ), - ], - ), - ), - Expanded( - child: Stack( - fit: StackFit.expand, - children: [ - if (_usesWebRtcScanner) - webrtc_zxing.ReaderWidget( - codeFormat: webrtc_zxing.Format.qrCode, - cropPercent: 0.7, - scanDelay: const Duration(milliseconds: 250), - scanDelaySuccess: const Duration(milliseconds: 250), - showGallery: false, - showToggleCamera: false, - showScannerOverlay: false, - onScan: (code) => _complete(code.text), - onRendererCreated: _onWebRtcScannerCreated, - ) - else if (_usesMobileScanner) - MobileScanner( - onDetect: _onMobileScan, - errorBuilder: (context, error) => Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text( - error.toString(), - style: const TextStyle(color: Colors.white), - textAlign: TextAlign.center, - ), - ), - ), - ) - else - Center( - child: Text( - l10n.cameraNotAvailable, - style: const TextStyle(color: Colors.white70), - textAlign: TextAlign.center, - ), - ), - Center( - child: Container( - width: 250, - height: 250, - decoration: BoxDecoration( - border: Border.all(color: Colors.white, width: 2), - borderRadius: BorderRadius.circular(12), - ), - ), - ), - if (_error != null) - Align( - alignment: Alignment.bottomCenter, - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - color: Colors.black87, - child: Text( - _error!, - style: const TextStyle(color: Colors.white), - textAlign: TextAlign.center, - ), - ), - ), - ], - ), - ), - ], - ), - ), - ); - } -} - -class _WalletChooserDialog extends StatefulWidget { - final WalletInputScannerConfiguration configuration; - - const _WalletChooserDialog({required this.configuration}); - - @override - State<_WalletChooserDialog> createState() => _WalletChooserDialogState(); -} - -class _WalletChooserDialogState extends State<_WalletChooserDialog> { - WalletInputScannerConfiguration get configuration => widget.configuration; - - @override - void initState() { - super.initState(); - if (configuration.openCashuMintChooserInitially) { + }, + errorBuilder: (context, error) { + // Report after build so the parent can display its shared error UI. WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _openCashu(context); + if (context.mounted) onError(error); }); - } - } - - WalletScannerConnectionOption? get _coinosOption { - for (final option in configuration.connectionOptions) { - if (option.id == 'coinos') return option; - } - return null; - } - - WalletScannerConnectionOption? get _installedWalletOption { - for (final option in configuration.connectionOptions) { - if (option.kind == WalletScannerConnectionKind.installedWallet) { - return option; - } - } - return null; - } - - Future _manualNwc(BuildContext context) async { - final result = await showDialog<_ManualWalletInputResult>( - context: context, - builder: (_) => _ManualWalletInputDialog( - initialValue: '', - supportedInputDescription: configuration.supportedInputDescription, - nwcOnly: true, - connectWalletApp: _installedWalletOption?.connect, - ), - ); - if (result != null && context.mounted) { - Navigator.of(context).pop( - result.connectionStarted - ? const WalletInputScanResult.connectionStarted() - : WalletInputScanResult.value( - result.value, - manuallyEntered: true, - origin: WalletInputOrigin.walletChooser, - ), - ); - } - } - - Future _connectCoinos(BuildContext context) async { - final option = _coinosOption; - if (option == null) return; - await option.connect(); - if (context.mounted) { - Navigator.of(context).pop( - const WalletInputScanResult.connectionStarted(), - ); - } - } - - Future _openAlby(BuildContext context) async { - final result = await showDialog( - context: context, - builder: (_) => _AlbyChooserDialog(configuration: configuration), - ); - if (result != null && context.mounted) Navigator.of(context).pop(result); - } - - Future _openCashu(BuildContext context) async { - final result = await showDialog( - context: context, - builder: (_) => _CashuMintChooserDialog(configuration: configuration), - ); - if (result != null && context.mounted) Navigator.of(context).pop(result); - } - - Future _openLnBits(BuildContext context) async { - final result = await showDialog( - context: context, - builder: (_) => _LnBitsConnectionDialog( - validate: configuration.validateLnBitsConnection, - ), - ); - if (result != null && context.mounted) { - Navigator.of(context).pop(WalletInputScanResult.lnBits(result)); - } - } - - void _returnToScanner(BuildContext context) { - Navigator.of(context).pop(); - } - - @override - Widget build(BuildContext context) { - final l10n = ndk_l10n.AppLocalizations.of(context)!; - - return AlertDialog( - title: Row( - children: [ - Expanded(child: Text(l10n.chooseWallet)), - IconButton( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.close), - ), - ], - ), - content: SizedBox( - width: 440, - child: GridView.count( - shrinkWrap: true, - crossAxisCount: 2, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - mainAxisExtent: 148, - children: [ - _WalletGridTile( - label: l10n.scanWalletQrCode, - icon: const _BrandIconFrame( - backgroundColor: Colors.black, - child: Icon( - Icons.qr_code_scanner, - color: Colors.white, - size: 42, - ), - ), - onTap: () => _returnToScanner(context), - ), - _WalletGridTile( - label: l10n.albyWalletOption, - icon: const _AlbyHubIcon(), - onTap: () => _openAlby(context), - ), - _WalletGridTile( - label: l10n.coinosWalletOption, - icon: const _CoinosIcon(), - onTap: () => _connectCoinos(context), - ), - _WalletGridTile( - label: l10n.cashuOption, - icon: const _BrandIconFrame( - backgroundColor: Color(0xFFFFF3D7), - child: Image( - image: AssetImage( - 'assets/images/cashu.png', - package: 'ndk_flutter', - ), - width: 44, - height: 44, - ), - ), - onTap: () => _openCashu(context), - ), - _WalletGridTile( - label: 'NWC', - icon: const _NwcIcon(), - onTap: () => _manualNwc(context), - ), - _WalletGridTile( - label: l10n.lnbitsWalletOption, - icon: const _LnBitsIcon(), - onTap: () => _openLnBits(context), - ), - ], - ), - ), - ); - } -} - -class _LnBitsConnectionDialog extends StatefulWidget { - final Future Function(LnBitsConnectionInput input)? - validate; - - const _LnBitsConnectionDialog({this.validate}); - - @override - State<_LnBitsConnectionDialog> createState() => - _LnBitsConnectionDialogState(); -} - -class _LnBitsConnectionDialogState extends State<_LnBitsConnectionDialog> { - final _adminKeyController = TextEditingController(); - final _urlController = TextEditingController(text: 'https://'); - bool _showAdminKey = false; - bool _readOnly = false; - bool _isValidating = false; - String? _error; - - Future _scanInto(TextEditingController controller) async { - final value = await showDialog( - context: context, - builder: (_) => const _QrCodeScannerDialog(), - ); - if (!mounted || value == null) return; - final normalized = value.trim(); - controller.text = normalized; - controller.selection = TextSelection.collapsed(offset: normalized.length); - setState(() => _error = null); - } - - @override - void dispose() { - _adminKeyController.dispose(); - _urlController.dispose(); - super.dispose(); - } - - Future _continue() async { - final l10n = ndk_l10n.AppLocalizations.of(context)!; - final adminKey = _adminKeyController.text.trim(); - final url = _urlController.text.trim(); - if (adminKey.isEmpty || url.isEmpty || url == 'https://') { - setState(() => _error = l10n.lnbitsCredentialsRequired); - return; - } - final input = LnBitsConnectionInput( - url: url, - adminKey: adminKey, - readOnly: _readOnly, - ); - final validate = widget.validate; - if (validate == null) { - Navigator.of(context).pop(input); - return; - } - setState(() { - _isValidating = true; - _error = null; - }); - try { - final validated = await validate(input); - if (mounted) Navigator.of(context).pop(validated); - } catch (error) { - if (mounted) setState(() => _error = error.toString()); - } finally { - if (mounted) setState(() => _isValidating = false); - } - } - - @override - Widget build(BuildContext context) { - final l10n = ndk_l10n.AppLocalizations.of(context)!; - return AlertDialog( - title: Row( - children: [ - const _LnBitsIcon(), - const SizedBox(width: 16), - Expanded(child: Text(l10n.lnbitsWalletOption)), - IconButton( - onPressed: _isValidating ? null : () => Navigator.of(context).pop(), - icon: const Icon(Icons.close), - ), - ], - ), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text(l10n.lnbitsConnectionInstructions), - const SizedBox(height: 20), - DropdownButtonFormField( - initialValue: _readOnly, - isExpanded: true, - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: l10n.lnbitsKeyType, - ), - items: [ - DropdownMenuItem( - value: false, - child: Text(l10n.lnbitsAdminKey), - ), - DropdownMenuItem( - value: true, - child: Text(l10n.lnbitsInvoiceReadKey), - ), - ], - onChanged: _isValidating - ? null - : (value) => setState(() => _readOnly = value ?? false), - ), - if (_readOnly) ...[ - const SizedBox(height: 12), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - Icons.lock_outline, - size: 20, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 8), - Expanded(child: Text(l10n.lnbitsReadOnlyDescription)), - ], - ), - ], - const SizedBox(height: 16), - TextField( - controller: _adminKeyController, - enabled: !_isValidating, - obscureText: !_showAdminKey, - enableSuggestions: false, - autocorrect: false, - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: - _readOnly ? l10n.lnbitsInvoiceReadKey : l10n.lnbitsAdminKey, - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: _isValidating - ? null - : () => _scanInto(_adminKeyController), - tooltip: l10n.scanWalletQrCode, - icon: const Icon(Icons.qr_code_scanner), - ), - IconButton( - onPressed: () => setState(() { - _showAdminKey = !_showAdminKey; - }), - icon: Icon( - _showAdminKey ? Icons.visibility_off : Icons.visibility, - ), - ), - ], - ), - ), - ), - const SizedBox(height: 16), - TextField( - controller: _urlController, - enabled: !_isValidating, - keyboardType: TextInputType.url, - autocorrect: false, - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: l10n.lnbitsUrl, - suffixIcon: IconButton( - onPressed: - _isValidating ? null : () => _scanInto(_urlController), - tooltip: l10n.scanWalletQrCode, - icon: const Icon(Icons.qr_code_scanner), - ), - ), - ), - if (_error != null) ...[ - const SizedBox(height: 12), - Text( - _error!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - ], - ], - ), - ), - actions: [ - TextButton( - onPressed: _isValidating ? null : () => Navigator.of(context).pop(), - child: Text(l10n.cancel), - ), - FilledButton( - onPressed: _isValidating ? null : _continue, - child: _isValidating - ? const SizedBox.square( - dimension: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text(l10n.reviewWallet), - ), - ], - ); - } -} - -class _CashuMintChooserDialog extends StatefulWidget { - final WalletInputScannerConfiguration configuration; - - const _CashuMintChooserDialog({required this.configuration}); - - @override - State<_CashuMintChooserDialog> createState() => - _CashuMintChooserDialogState(); -} - -class _CashuMintChooserDialogState extends State<_CashuMintChooserDialog> { - late Future> _suggestions; - - @override - void initState() { - super.initState(); - _suggestions = widget.configuration.discoverCashuMints(); - } - - void _retry() { - setState(() { - _suggestions = widget.configuration.discoverCashuMints(); - }); - } - - Future _enterMintUrl() async { - final l10n = ndk_l10n.AppLocalizations.of(context)!; - final result = await showDialog<_ManualWalletInputResult>( - context: context, - builder: (_) => _ManualWalletInputDialog( - initialValue: 'https://', - supportedInputDescription: l10n.enterMintUrl, - nwcOnly: false, - ), - ); - final value = result?.value?.trim(); - if (!mounted || value == null) return; - if (classifyWalletInput(value) != WalletInputKind.cashuMint) return; - Navigator.of(context).pop( - WalletInputScanResult.value( - value, - manuallyEntered: true, - origin: WalletInputOrigin.cashuMintChooser, - ), - ); - } - - void _selectMint(CashuMintSuggestion mint) { - Navigator.of(context).pop( - WalletInputScanResult.value( - mint.url, - origin: WalletInputOrigin.cashuMintChooser, - cashuMintSuggestion: mint, - ), - ); - } - - @override - Widget build(BuildContext context) { - final l10n = ndk_l10n.AppLocalizations.of(context)!; - return AlertDialog( - title: Row( - children: [ - Expanded(child: Text(l10n.chooseCashuMint)), - IconButton( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.close), - ), - ], - ), - content: SizedBox( - width: 460, - height: 480, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - l10n.cashuMintRatingsNotice, - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 12), - Expanded( - child: FutureBuilder>( - future: _suggestions, - builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done) { - return const Center(child: CircularProgressIndicator()); - } - if (snapshot.hasError) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - l10n.cashuMintDiscoveryFailed, - textAlign: TextAlign.center, - ), - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: _retry, - icon: const Icon(Icons.refresh), - label: Text(l10n.retry), - ), - ], - ), - ); - } - final suggestions = snapshot.data ?? const []; - if (suggestions.isEmpty) { - return Center(child: Text(l10n.noCashuMintSuggestions)); - } - return ListView.separated( - scrollCacheExtent: const ScrollCacheExtent.pixels(0), - itemCount: suggestions.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (context, index) { - final mint = suggestions[index]; - return _CashuMintListTile( - mint: mint, - enrich: widget.configuration.enrichCashuMint, - onSelected: _selectMint, - ); - }, - ); - }, - ), - ), - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: _enterMintUrl, - icon: const Icon(Icons.edit_outlined), - label: Text(l10n.enterMintUrlManually), - ), - ], - ), - ), - ); - } -} - -class _CashuMintListTile extends StatefulWidget { - final CashuMintSuggestion mint; - final Future Function(CashuMintSuggestion mint) enrich; - final ValueChanged onSelected; - - const _CashuMintListTile({ - required this.mint, - required this.enrich, - required this.onSelected, - }); - - @override - State<_CashuMintListTile> createState() => _CashuMintListTileState(); -} - -class _CashuMintListTileState extends State<_CashuMintListTile> { - late final Future _enriched; - bool _selecting = false; - - @override - void initState() { - super.initState(); - _enriched = widget.enrich(widget.mint); - } - - Future _select() async { - if (_selecting) return; - setState(() => _selecting = true); - CashuMintSuggestion mint; - try { - mint = await _enriched; - } catch (_) { - mint = widget.mint; - } - if (mounted) widget.onSelected(mint); - } - - @override - Widget build(BuildContext context) { - final l10n = ndk_l10n.AppLocalizations.of(context)!; - return FutureBuilder( - future: _enriched, - initialData: widget.mint, - builder: (context, snapshot) { - final mint = snapshot.data ?? widget.mint; - final rating = mint.averageRating; - return ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 4, - ), - leading: _CashuMintIcon(mint: mint), - title: Text( - mint.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - mint.url, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text( - rating == null - ? l10n.noRatingsYet - : l10n.cashuMintRating( - rating.toStringAsFixed(1), - mint.reviewsCount, - ), - ), - ], - ), - trailing: _selecting - ? const SizedBox.square( - dimension: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.chevron_right), - onTap: _selecting ? null : _select, - ); - }, - ); - } -} - -class _AlbyChooserDialog extends StatelessWidget { - final WalletInputScannerConfiguration configuration; - - const _AlbyChooserDialog({required this.configuration}); - - WalletScannerConnectionOption? get _albyGoOption { - for (final option in configuration.connectionOptions) { - if (option.kind == WalletScannerConnectionKind.albyGo) return option; - } - return null; - } - - WalletScannerConnectionOption? get _albyCloudOption { - for (final option in configuration.connectionOptions) { - if (option.id == 'alby-cloud') return option; - } - return null; - } - - Future _openCloud(BuildContext context) async { - final option = _albyCloudOption; - if (option == null) return; - await option.connect(); - if (context.mounted) { - Navigator.of(context).pop( - const WalletInputScanResult.connectionStarted(), - ); - } - } - - Future _connectAlbyGo(BuildContext context) async { - final option = _albyGoOption; - if (option == null) return; - await option.connect(); - if (context.mounted) { - Navigator.of(context).pop( - const WalletInputScanResult.connectionStarted(), - ); - } - } - - Future _manualNwc(BuildContext context) async { - final result = await showDialog<_ManualWalletInputResult>( - context: context, - builder: (_) => _ManualWalletInputDialog( - initialValue: '', - supportedInputDescription: configuration.supportedInputDescription, - nwcOnly: true, - ), - ); - if (result?.value != null && context.mounted) { - Navigator.of(context).pop( - WalletInputScanResult.value( - result!.value, - manuallyEntered: true, - origin: WalletInputOrigin.walletChooser, - providerId: 'alby', - ), - ); - } - } - - @override - Widget build(BuildContext context) { - final l10n = ndk_l10n.AppLocalizations.of(context)!; - return AlertDialog( - title: Text(l10n.albyWalletOption), - content: SizedBox( - width: 420, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const _AlbyHubIcon(), - title: Text(l10n.albyCloudOption), - subtitle: const Text('my.albyhub.com'), - trailing: const Icon(Icons.open_in_new), - enabled: _albyCloudOption != null, - onTap: - _albyCloudOption == null ? null : () => _openCloud(context), - ), - const SizedBox(height: 16), - ListTile( - leading: const _AlbyGoIcon(), - title: Text(l10n.albyGoOption), - trailing: const Icon(Icons.chevron_right), - enabled: _albyGoOption != null, - onTap: - _albyGoOption == null ? null : () => _connectAlbyGo(context), - ), - const SizedBox(height: 16), - ListTile( - leading: const _NwcIcon(), - title: Text(l10n.manualNwcConnection), - trailing: const Icon(Icons.chevron_right), - onTap: () => _manualNwc(context), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(l10n.cancel), - ), - ], - ); - } -} - -class _AlbyHubIcon extends StatelessWidget { - const _AlbyHubIcon(); - - @override - Widget build(BuildContext context) { - return _BrandIconFrame( - backgroundColor: Colors.black, - child: SvgPicture.asset( - 'assets/images/albyhub.svg', - package: 'ndk_flutter', - width: 40, - height: 40, - ), - ); - } -} - -class _AlbyGoIcon extends StatelessWidget { - const _AlbyGoIcon(); - - @override - Widget build(BuildContext context) { - return _BrandIconFrame( - backgroundColor: Colors.white, - child: Image.asset( - 'assets/images/albygo.png', - package: 'ndk_flutter', - width: 40, - height: 40, - ), - ); - } -} - -class _CashuMintIcon extends StatelessWidget { - final CashuMintSuggestion mint; - - const _CashuMintIcon({required this.mint}); - - @override - Widget build(BuildContext context) { - final fallback = Icon( - Icons.toll_outlined, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ); - final iconUrl = mint.iconUrl; - - return CircleAvatar( - radius: 24, - backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, - child: ClipOval( - child: iconUrl == null - ? fallback - : Image.network( - iconUrl, - width: 48, - height: 48, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => fallback, - ), - ), - ); - } -} - -class _CoinosIcon extends StatelessWidget { - const _CoinosIcon(); - - @override - Widget build(BuildContext context) { - return _BrandIconFrame( - backgroundColor: Colors.white, - child: SvgPicture.asset( - 'assets/images/coinos.svg', - package: 'ndk_flutter', - width: 48, - height: 48, - ), - ); - } -} - -class _NwcIcon extends StatelessWidget { - const _NwcIcon(); - - @override - Widget build(BuildContext context) { - return _BrandIconFrame( - backgroundColor: Colors.white, - child: Image.asset( - 'assets/images/nwc.png', - package: 'ndk_flutter', - width: 48, - height: 48, - fit: BoxFit.contain, - ), - ); - } -} - -class _LnBitsIcon extends StatelessWidget { - const _LnBitsIcon(); - - @override - Widget build(BuildContext context) { - return _BrandIconFrame( - backgroundColor: const Color(0xFF673AB7), - child: SvgPicture.asset( - 'assets/images/lnbits.svg', - package: 'ndk_flutter', - width: 64, - height: 64, - ), - ); - } -} - -class _BrandIconFrame extends StatelessWidget { - final Color backgroundColor; - final Widget child; - - const _BrandIconFrame({required this.backgroundColor, required this.child}); - - @override - Widget build(BuildContext context) { - final dark = Theme.of(context).brightness == Brightness.dark; - - return Container( - width: 64, - height: 64, - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: dark - ? Colors.white.withValues(alpha: 0.15) - : Colors.black.withValues(alpha: 0.25), - offset: const Offset(0, 10), - blurRadius: 15, - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: ColoredBox( - color: backgroundColor, - child: Center(child: child), - ), - ), - ); - } -} - -class _WalletGridTile extends StatelessWidget { - final String label; - final Widget icon; - final VoidCallback onTap; - - const _WalletGridTile({ - required this.label, - required this.icon, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return Material( - color: Theme.of(context).colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(16), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(16), - child: Padding( - padding: const EdgeInsets.fromLTRB(8, 12, 8, 8), - child: Column( - children: [ - icon, - const SizedBox(height: 8), - Expanded( - child: Center( - child: Tooltip( - message: label, - child: Text( - label, - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium, - ), - ), - ), - ), - ], - ), - ), - ), - ); - } + return const SizedBox.shrink(); + }, + ); } diff --git a/packages/sample-app/lib/wallets.dart b/packages/sample-app/lib/wallets.dart index fba646c1e..5bf5a4dd6 100644 --- a/packages/sample-app/lib/wallets.dart +++ b/packages/sample-app/lib/wallets.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:ndk_demo/l10n/app_localizations_context.dart'; import 'package:ndk_flutter/ndk_flutter.dart'; @@ -5,12 +6,6 @@ import 'package:ndk_flutter/ndk_flutter.dart'; import 'main.dart'; import 'nwc_qr_scanner.dart'; -const _sampleAppName = 'NDK Demo'; -const _sampleCallback = 'ndk://nwc'; -const _coinosRelay = 'wss://relay.coinos.io'; -const _coinosWalletServicePubkey = - 'ba80990666ef0b6f4ba5059347beb13242921e54669e680064ca755256a1e3a6'; - class WalletsPage extends StatefulWidget { final String? initialUrl; @@ -82,45 +77,12 @@ class WalletsPageState extends State with WidgetsBindingObserver { body: NWallets( key: _walletsKey, ndkFlutter: ndkFlutter, - walletInputScanner: scanWalletInput, - nwcConnectionOptions: [ - NwcConnectionOption( - id: 'alby-cloud', - label: 'Alby Cloud', - connect: (context, ndkFlutter, coordinator) { - return coordinator.connectWebWalletAuth( - context, - authorizationEndpoint: Uri.parse( - 'https://my.albyhub.com/apps/new', - ), - appName: _sampleAppName, - discoveryRelay: kDefaultAlbyGoConnectConfig.discoveryRelay, - callback: _sampleCallback, - walletName: 'Alby Cloud', - providerId: 'alby', - additionalQueryParameters: const { - 'return_to': _sampleCallback, - }, - ); - }, - ), - NwcConnectionOption( - id: 'coinos', - label: 'Coinos', - connect: (context, ndkFlutter, coordinator) { - return coordinator.connectWebWalletAuth( - context, - authorizationEndpoint: Uri.parse('https://coinos.io/apps/new'), - appName: _sampleAppName, - discoveryRelay: _coinosRelay, - callback: _sampleCallback, - walletName: 'Coinos', - providerId: 'coinos', - walletServicePubkey: _coinosWalletServicePubkey, - ); - }, - ), - ], + walletQrScannerBuilder: kIsWeb || + defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.linux + ? buildWalletQrScanner + : null, ), ); } diff --git a/packages/sample-app/pubspec.lock b/packages/sample-app/pubspec.lock index 99d48086f..938a8faa2 100644 --- a/packages/sample-app/pubspec.lock +++ b/packages/sample-app/pubspec.lock @@ -364,7 +364,7 @@ packages: source: hosted version: "4.1.0" flutter_svg: - dependency: "direct main" + dependency: transitive description: name: flutter_svg sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" @@ -382,7 +382,7 @@ packages: source: sdk version: "0.0.0" flutter_webrtc: - dependency: transitive + dependency: "direct main" description: name: flutter_webrtc sha256: "381e05c120caf2f1ee1accd806baad22b33802f36c74d8ea5e43a5800ce6380c" @@ -454,7 +454,7 @@ packages: source: hosted version: "2.9.2" image: - dependency: transitive + dependency: "direct main" description: name: image sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce diff --git a/packages/sample-app/pubspec.yaml b/packages/sample-app/pubspec.yaml index e8577c708..e57d14f29 100644 --- a/packages/sample-app/pubspec.yaml +++ b/packages/sample-app/pubspec.yaml @@ -54,7 +54,8 @@ dependencies: qr_flutter: ^4.1.0 mobile_scanner: ^7.2.1 flutter_webrtc_zxing: ^0.2.1 - flutter_svg: ^2.2.1 + flutter_webrtc: ^1.6.2+hotfix.1 + image: ^4.8.0 convert: ^3.1.2 crypto: ^3.0.7 diff --git a/packages/sample-app/test/linux_qr_scanner_test.dart b/packages/sample-app/test/linux_qr_scanner_test.dart new file mode 100644 index 000000000..9b8b9ed29 --- /dev/null +++ b/packages/sample-app/test/linux_qr_scanner_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_webrtc_zxing/flutter_webrtc_zxing.dart' as zxing; +import 'package:image/image.dart' as image; +import 'package:ndk_demo/linux_qr_scanner.dart'; + +// Run on Linux after flutter build linux --debug with bundle/lib on +// LD_LIBRARY_PATH so the real native ZXing decoder is available. +const offer = + 'lno1pgqppmsrse80qf0aara4slvcjxrvu6j2rp5ftmjy4yntlsmsutpkvkt6878sx37ttar5fpecarm57v2y2can2uxq02l7k0er7czs6gsuzkdhe4tlqgpat4k4mrvvjwla3whdhmkvdtfq98w4jlg8wgsf26cndmndd0c33fqqx0y9hunesw4caaxfnw3uam5yy4kxtuqvujapdx93sd24wt7mdpeukuw46tp5zugxceqrr2ffkzpjcen3p77sy8jk8v7h04wlp9lg6ls76xqcn3nethq7e7553xn3vugt5vzlea2sqqedvc6k8r8hetzw9tvnlnw9muh4vaywdn5jgvj80ad3r9600ang39vvjnvn0aytg07ss05v6g9ru45p2srs'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Future makeFrame({int left = 795, bool inverted = false}) async { + const size = 330; + final encoded = await zxing.zx.encodeBarcode( + contents: offer, + params: zxing.EncodeParams(width: size, height: size, margin: 4), + ); + expect(encoded.isValid, isTrue); + expect(encoded.data!.length, size * size); + final qr = image.Image.fromBytes( + width: size, + height: size, + bytes: encoded.data!.buffer, + numChannels: 1, + ); + final frame = image.Image(width: 1920, height: 1080); + image.fill(frame, color: image.ColorRgb8(255, 255, 255)); + image.compositeImage(frame, qr, dstX: left, dstY: 375); + return inverted ? image.invert(frame) : frame; + } + + test('decodes dense offer at original resolution in background isolate', + () async { + final frame = await makeFrame(); + expect(await compute(decodeLinuxQrFrame, image.encodePng(frame)), offer); + + // Reproduce the old ReaderWidget's destructive 768px resize. + final reduced = zxing.resizeToMaxSize(frame, 768); + final oldResult = zxing.zx.readBarcode( + zxing.rgbBytes(reduced), + zxing.DecodeParams( + width: reduced.width, + height: reduced.height, + imageFormat: zxing.ImageFormat.rgb, + format: zxing.Format.qrCode, + ), + ); + expect(oldResult.isValid, isFalse); + }); + + test('decodes offer outside the old central crop', () async { + final frame = await makeFrame(left: 100); + expect(decodeLinuxQrFrame(image.encodePng(frame)), offer); + }); + + test('decodes inverted dense offer', () async { + final frame = await makeFrame(inverted: true); + expect(decodeLinuxQrFrame(image.encodePng(frame)), offer); + }); + + test('blank frame returns no result', () { + final frame = image.Image(width: 320, height: 240); + image.fill(frame, color: image.ColorRgb8(255, 255, 255)); + expect(decodeLinuxQrFrame(image.encodePng(frame)), isNull); + }); +}