diff --git a/doc/ndk_flutter/qr-scanner.md b/doc/ndk_flutter/qr-scanner.md index bbc861072..29e07ae96 100644 --- a/doc/ndk_flutter/qr-scanner.md +++ b/doc/ndk_flutter/qr-scanner.md @@ -6,36 +6,145 @@ 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. +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 -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. 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`: + +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, + 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. 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( + 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', + allowUntaggedInfoEvent: true, + ); + }, +) +``` + +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 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.connectInstalledWallet( + context, + config: const AlbyGoConnectConfig( + appName: 'My app', + appIconUrl: 'https://example.com/icon.png', + callback: 'myapp://nwc', + ), +); +``` + +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 @@ -49,8 +158,7 @@ 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. +`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/drift/lib/src/drift_cache_manager.dart b/packages/drift/lib/src/drift_cache_manager.dart index 898b8d59c..30816f0f8 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'; @@ -1968,37 +1963,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..0ccb27144 100644 --- a/packages/drift/test/drift_cache_manager_test.dart +++ b/packages/drift/test/drift_cache_manager_test.dart @@ -1,10 +1,47 @@ 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', + 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/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 d42ce42a0..2813a3a3c 100644 --- a/packages/ndk/example/nwc/connect_get_info.dart +++ b/packages/ndk/example/nwc/connect_get_info.dart @@ -20,6 +20,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/example/wallets/send.dart b/packages/ndk/example/wallets/send.dart index 519555513..41995571d 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,12 @@ 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..410a563b2 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,8 @@ class WalletTransactionModel { case WalletType.NWC: return NwcWalletTransactionModel.fromJson(json); case WalletType.LNURL: + case WalletType.BOLT12: + case WalletType.LNBITS: return LnurlWalletTransactionModel.fromJson(json); } } 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/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/entities/wallet/bip321.dart b/packages/ndk/lib/domain_layer/entities/wallet/bip321.dart new file mode 100644 index 000000000..e92dab7a3 --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/wallet/bip321.dart @@ -0,0 +1,92 @@ +/// 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 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) { + throw UnsupportedError( + 'Unsupported required BIP-321 parameter: ' + '${requiredParameters.first}', + ); + } + + final instructions = parameters['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|tbs|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/bolt12/bolt12_wallet.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet.dart new file mode 100644 index 000000000..1c7f6a21d --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet.dart @@ -0,0 +1,113 @@ +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? amount; + final String? issuer; + final String? currency; + final int? expiresAt; + final int? quantityMax; + final bool hasBlindedPaths; + + Bolt12Wallet({ + required super.id, + required super.name, + required super.supportedUnits, + required this.offer, + required this.source, + this.bip353Address, + this.description, + this.nodeId, + this.amount, + this.issuer, + this.currency, + this.expiresAt, + this.quantityMax, + this.hasBlindedPaths = false, + Map? metadata, + }) : super( + type: WalletType.BOLT12, + metadata: Map.unmodifiable({ + ...(metadata ?? const {}), + 'offer': offer, + 'source': source, + 'bip353Address': bip353Address, + 'description': description, + 'nodeId': nodeId, + '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?, + 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..52f201769 --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet_provider.dart @@ -0,0 +1,643 @@ +import 'dart:async'; +import 'dart:convert'; + +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'; +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']), + '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 { + static final Uri defaultBip353DohEndpoint = Uri.parse( + 'https://cloudflare-dns.com/dns-query', + ); + + 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, + Uri? bip353DohEndpoint, + http.Client? httpClient, + }) 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 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', + ); + } + + return _validate( + offer: resolvedOffer, + source: source, + bip353Address: address, + ); + } + + 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({ + required String offer, + required String source, + String? bip353Address, + }) { + final envelope = _Bolt12OfferEnvelope.parse(offer); + final canonicalOffer = envelope.canonicalOffer; + + return Bolt12ResolvedOffer( + offer: canonicalOffer, + source: source, + bip353Address: bip353Address, + decoded: envelope.details, + ); + } + + 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?, + 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 { + final offer = (wallet as Bolt12Wallet).offer; + return Uri( + scheme: 'bitcoin', + queryParameters: { + 'amount': _formatBitcoinAmount(amountSats, 100000000), + 'lno': offer, + }, + ).toString(); + } + + @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: { + 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([]); +} + +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, + }); + + 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/cashu/cashu_wallet_provider.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/cashu/cashu_wallet_provider.dart index 093d9eba1..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 @@ -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}'); } @@ -151,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 { @@ -195,4 +217,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/lnbits/lnbits_wallet.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/lnbits/lnbits_wallet.dart new file mode 100644 index 000000000..56ee799ed --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/lnbits/lnbits_wallet.dart @@ -0,0 +1,74 @@ +import '../../wallet.dart'; +import '../../wallet_type.dart'; + +/// 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, + required super.name, + super.type = WalletType.LNBITS, + required super.supportedUnits, + 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, + }), + ); + + @override + bool get canReceive => true; + + @override + bool get canSend => !readOnly; + + @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?, + 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 new file mode 100644 index 000000000..77f4a333b --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/lnbits/lnbits_wallet_provider.dart @@ -0,0 +1,474 @@ +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 { + static const balanceRefreshInterval = Duration(seconds: 30); + + 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(), + readOnly: metadata[LnBitsWallet.readOnlyMetadataKey] as bool? ?? false, + 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, + readOnly: lnbitsWallet.readOnly, + metadata: lnbitsWallet.metadata, + ); + } + + @override + Future removeWallet(Wallet wallet) async {} + + @override + Stream> getBalances(Wallet wallet) async* { + final lnbitsWallet = _asLnBitsWallet(wallet); + while (true) { + final info = await _getWalletInfo( + lnbitsUrl: lnbitsWallet.lnbitsUrl, + adminKey: lnbitsWallet.adminKey, + ); + yield [ + WalletBalance( + walletId: wallet.id, + unit: 'sat', + amount: info.balanceMsat ~/ 1000, + ), + ]; + await Future.delayed(balanceRefreshInterval); + } + } + + @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 { + if (_asLnBitsWallet(wallet).readOnly) { + throw UnsupportedError('LNbits invoice/read key cannot send payments'); + } + 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 (_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'); + } + 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/providers/lnurl/lnurl_wallet_provider.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/lnurl/lnurl_wallet_provider.dart index 81a2a7d63..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 @@ -4,7 +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'; @@ -68,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 @@ -166,6 +169,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 41f6b3867..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 @@ -10,9 +10,13 @@ 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'; + static const String kRequireAuthenticatedResponseMetadataKey = + 'requireAuthenticatedResponse'; final String nwcUrl; final Set cachedPermissions; + final String? providerId; NwcConnection? connection; /// Remaining NWC budget in sats, cached after the last `get_budget` call. @@ -25,12 +29,16 @@ class NwcWallet extends Wallet { bool isConnected() => connection != null; + bool get requireAuthenticatedResponse => + metadata[kRequireAuthenticatedResponseMetadataKey] == true; + NwcWallet({ required super.id, required super.name, super.type = WalletType.NWC, required super.supportedUnits, required this.nwcUrl, + this.providerId, Set cachedPermissions = const {}, Map? metadata, }) : cachedPermissions = Set.unmodifiable(cachedPermissions), @@ -38,6 +46,7 @@ class NwcWallet extends Wallet { metadata: Map.unmodifiable({ ...(metadata ?? const {}), 'nwcUrl': nwcUrl, + if (providerId != null) kProviderIdMetadataKey: providerId, kPermissionsMetadataKey: cachedPermissions.toList(), }), ); @@ -63,6 +72,7 @@ class NwcWallet extends Wallet { name: name, supportedUnits: supportedUnits, nwcUrl: nwcUrl, + providerId: metadata[kProviderIdMetadataKey] as String?, cachedPermissions: _parsePermissions(metadata[kPermissionsMetadataKey]), metadata: metadata, ); @@ -74,6 +84,7 @@ class NwcWallet extends Wallet { name: name, supportedUnits: supportedUnits, nwcUrl: nwcUrl, + providerId: providerId, cachedPermissions: permissions, metadata: metadata, ); @@ -94,16 +105,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); + supportsMethod(NwcMethod.MAKE_INVOICE) || + supportsMethod(NwcMethod.RECEIVE); @override bool get canSend => - _effectivePermissions.contains(NwcMethod.PAY_INVOICE.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/providers/nwc/nwc_wallet_provider.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet_provider.dart index e81ab5980..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 @@ -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'; @@ -49,6 +51,7 @@ class NwcWalletProvider implements WalletProvider { name: name, supportedUnits: supportedUnits, nwcUrl: nwcUrl, + providerId: metadata[NwcWallet.kProviderIdMetadataKey] as String?, metadata: metadata, ); } @@ -186,6 +189,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; @@ -331,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/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..208cbe2ee 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,18 @@ 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'; + +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'; @@ -59,6 +67,20 @@ class WalletFactory { supportedUnits: supportedUnits, metadata: metadata, ); + case WalletType.BOLT12: + return Bolt12Wallet.fromStorage( + id: id, + name: name, + 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_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/entities/wallet/wallet_transaction.dart b/packages/ndk/lib/domain_layer/entities/wallet/wallet_transaction.dart index 201526423..c05537dea 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,8 @@ abstract class WalletTransaction { initiatedDate: initiatedDate, ); 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 3413275c0..241e7b5cb 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,11 @@ enum WalletType { // ignore: constant_identifier_names CASHU('cashu'), // ignore: constant_identifier_names - LNURL('lnurl'); + LNURL('lnurl'), + // ignore: constant_identifier_names + BOLT12('bolt12'), + // ignore: constant_identifier_names + LNBITS('lnbits'); final String value; 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..b76d198e0 --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_mint_recommendations.dart @@ -0,0 +1,199 @@ +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; + // 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; + } + } + + 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 + .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/domain_layer/usecases/nwc/consts/error_code.dart b/packages/ndk/lib/domain_layer/usecases/nwc/consts/error_code.dart index e0e941b06..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 @@ -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 7a0eecf9c..c70d0e7f3 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 @@ -61,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( @@ -121,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"); @@ -135,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), ); @@ -210,6 +229,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) { @@ -520,6 +543,48 @@ class Nwc { ); } + /// Pays a Lightning instruction from a BIP-321 URI using NWC-321. + Future pay( + NwcConnection connection, { + required String payment, + int? amountMsat, + int? maxFeeMsat, + String? payerNote, + Map? metadata, + Duration? timeout, + }) async { + return _executeRequest( + connection, + PayRequest( + payment: payment, + amountMsat: amountMsat, + maxFeeMsat: maxFeeMsat, + 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..ef83770d8 --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/nwc/requests/pay.dart @@ -0,0 +1,43 @@ +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; + + /// The maximum routing fee the sender is willing to pay, in millisatoshis. + final int? maxFeeMsat; + + /// An optional message from the payer. + final String? payerNote; + + /// Optional application-defined metadata. + final Map? metadata; + + const PayRequest({ + required this.payment, + this.amountMsat, + this.maxFeeMsat, + this.payerNote, + this.metadata, + }) : super(method: NwcMethod.PAY); + + @override + Map toMap() { + return { + ...super.toMap(), + '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, + }, + }; + } +} 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 f79b0b367..f052f8fda 100644 --- a/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart +++ b/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart @@ -5,11 +5,14 @@ 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'; 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 @@ -257,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(); @@ -484,6 +480,52 @@ 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; + } + + /// 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; @@ -575,6 +617,273 @@ 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, + ); + } + + /// 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; + await _refreshTransferredWalletData(source.id, destination.id); + 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', + ); + } + await _refreshTransferredWalletData(source.id, destination.id); + return WalletTransferResult( + sourceWalletId: source.id, + destinationWalletId: destination.id, + protocol: WalletPaymentProtocol.bolt11, + payment: payment, + receiveResponse: receiveResponse, + payInvoiceResponse: payInvoiceResponse, + ); + } + + 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, @@ -665,3 +974,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 ae23d1bec..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'; @@ -60,10 +61,13 @@ 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'; 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/ndk.dart b/packages/ndk/lib/ndk.dart index f79af77bb..f02f93a96 100644 --- a/packages/ndk/lib/ndk.dart +++ b/packages/ndk/lib/ndk.dart @@ -38,6 +38,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'; @@ -109,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 7a28adf20..6c6b4356d 100644 --- a/packages/ndk/lib/presentation_layer/init.dart +++ b/packages/ndk/lib/presentation_layer/init.dart @@ -19,6 +19,8 @@ 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/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'; @@ -32,6 +34,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'; @@ -253,6 +256,7 @@ class Initialization { cacheManager: _ndkConfig.cache, cashuUserSeedphrase: _ndkConfig.cashuUserSeedphrase, cashuKeyDerivation: DartCashuKeyDerivation(), + mintRecommendations: CashuMintRecommendations(requests: requests), ); // Create wallet providers @@ -315,6 +319,8 @@ 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); @@ -361,7 +367,13 @@ class Initialization { connectivity = Connectivy(relayManager); wallets = Wallets( - providers: [cashuProvider, nwcProvider, lnurlProvider], + providers: [ + cashuProvider, + nwcProvider, + lnurlProvider, + bolt12Provider, + lnbitsProvider, + ], repository: _ndkConfig.walletsRepo!, ); proofOfWork = ProofOfWork(); 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/cashu/cashu_mint_recommendations_test.dart b/packages/ndk/test/cashu/cashu_mint_recommendations_test.dart new file mode 100644 index 000000000..ae4f39d44 --- /dev/null +++ b/packages/ndk/test/cashu/cashu_mint_recommendations_test.dart @@ -0,0 +1,153 @@ +import 'package:ndk/ndk.dart'; +import 'package:ndk/domain_layer/entities/cashu/cashu_mint_info.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'); + }); + + 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)); + }); + }); +} + +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/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/bip321_test.dart b/packages/ndk/test/entities/bip321_test.dart new file mode 100644 index 000000000..2457fdb55 --- /dev/null +++ b/packages/ndk/test/entities/bip321_test.dart @@ -0,0 +1,70 @@ +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('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); + expect(Bip321.getBolt11AmountMsat('lnbc2m1paymentdata'), 200000000); + expect(Bip321.getBolt11AmountMsat('lntb3u1paymentdata'), 300000); + expect(Bip321.getBolt11AmountMsat('lnbcrt4n1paymentdata'), 400); + 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( + () => Bip321.getBolt11( + 'bitcoin:?lightning=lnbc1paymentdata&$requiredKey=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/bolt12_wallet_test.dart b/packages/ndk/test/entities/bolt12_wallet_test.dart new file mode 100644 index 000000000..9f620113f --- /dev/null +++ b/packages/ndk/test/entities/bolt12_wallet_test.dart @@ -0,0 +1,196 @@ +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'; + +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); + expect(resolved.toMetadata(), isNot(contains('offerId'))); + }); + + 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('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'), + 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.type, WalletType.BOLT12); + expect(wallet.canReceive, isTrue); + expect(wallet.canSend, isFalse); + 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()), + ); + + 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/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/test/entities/lnbits_wallet_test.dart b/packages/ndk/test/entities/lnbits_wallet_test.dart new file mode 100644 index 000000000..732c6af12 --- /dev/null +++ b/packages/ndk/test/entities/lnbits_wallet_test.dart @@ -0,0 +1,282 @@ +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('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'), + 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('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( + 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))), + ), + ); + }); + + 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 b8c25d01c..2cfbb6fc3 100644 --- a/packages/ndk/test/entities/nwc_wallet_test.dart +++ b/packages/ndk/test/entities/nwc_wallet_test.dart @@ -1,9 +1,29 @@ 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'; 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', @@ -16,11 +36,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]), @@ -34,6 +56,7 @@ void main() { supportedUnits: {'sat'}, nwcUrl: 'nostr+walletconnect://a?relay=wss://relay.example&secret=secret', + providerId: 'coinos', ); final updated = wallet.withCachedPermissions({ @@ -42,9 +65,39 @@ void main() { expect(updated.canSend, isTrue); expect(updated.canReceive, isFalse); + expect(updated.providerId, 'coinos'); expect(updated.metadata[NwcWallet.kPermissionsMetadataKey], [ 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); + 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.dart b/packages/ndk/test/usecases/lnurl/lnurl_test.dart index 4ef4ad564..2f539ea2e 100644 --- a/packages/ndk/test/usecases/lnurl/lnurl_test.dart +++ b/packages/ndk/test/usecases/lnurl/lnurl_test.dart @@ -5,7 +5,12 @@ 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/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'; import 'package:test/test.dart'; import 'lnurl_test.mocks.dart'; @@ -59,6 +64,73 @@ 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('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/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/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..9b1488cfd --- /dev/null +++ b/packages/ndk/test/usecases/wallets_bip321_test.dart @@ -0,0 +1,209 @@ +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)); + }); + + 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 { + _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); + + int balanceRequests = 0; + + 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) { + balanceRequests++; + return Stream.value([ + WalletBalance(walletId: wallet.id, unit: 'sat', amount: 42), + ]); + } + + @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; + } +} 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..5e6bc394e --- /dev/null +++ b/packages/ndk/test/usecases/wallets_transfer_test.dart @@ -0,0 +1,317 @@ +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); + 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( + 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); + 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', + () 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) + ..failBalanceRefresh = true + ..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(sourceProvider.balanceRequests, 1); + expect(destinationProvider.balanceRequests, 1); + 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; + int balanceRequests = 0; + int recentTransactionRequests = 0; + int pendingTransactionRequests = 0; + bool failBalanceRefresh = false; + + _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) { + 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) { + pendingTransactionRequests++; + return Stream.value(const []); + } + + @override + Stream> getRecentTransactions(Wallet wallet) { + recentTransactionRequests++; + return 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/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); + } + }); }); } 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 new file mode 100644 index 000000000..996c27917 --- /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/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 db7105196..6098afecb 100644 --- a/packages/ndk_flutter/lib/l10n/app_de.arb +++ b/packages/ndk_flutter/lib/l10n/app_de.arb @@ -1,4 +1,90 @@ { + "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", + "walletDetailWalletId": "Wallet-ID", + "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", + "walletUnreachable": "Wallet nicht erreichbar", + "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?", @@ -383,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", @@ -391,6 +478,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}", @@ -430,5 +519,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 96e4b575c..594ed988f 100644 --- a/packages/ndk_flutter/lib/l10n/app_en.arb +++ b/packages/ndk_flutter/lib/l10n/app_en.arb @@ -1,4 +1,19 @@ { + "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", + "walletDetailWalletId": "Wallet ID", + "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": { @@ -1015,6 +1030,19 @@ "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", + "noCompatibleReceivingWalletsDescription": "Add or connect another wallet that can receive a payment supported by this wallet.", + "destinationWallet": "Destination wallet", + "walletTransferSubmitted": "Payment sent to {walletName}", + "@walletTransferSubmitted": { + "placeholders": { + "walletName": { + "type": "String" + } + } + }, "@payInvoiceTitle": { "description": "Title for pay invoice dialog" }, @@ -1273,6 +1301,82 @@ "@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", + "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" + }, + "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" @@ -1294,6 +1398,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" }, @@ -1325,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" @@ -1354,6 +1475,8 @@ "description": "Error message for invalid NWC URI" }, "paste": "Paste", + "clearInput": "Clear input", + "pasteOrEnter": "Paste or type", "@paste": { "description": "Label for paste action" }, @@ -1496,5 +1619,90 @@ "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", + "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.", + "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.", + "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", + "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 3d3144e03..d117d5d85 100644 --- a/packages/ndk_flutter/lib/l10n/app_es.arb +++ b/packages/ndk_flutter/lib/l10n/app_es.arb @@ -1,4 +1,90 @@ { + "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", + "walletDetailWalletId": "ID de cartera", + "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", + "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", + "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í?", @@ -325,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", @@ -333,6 +420,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}", @@ -372,5 +461,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 67d30f16a..d890d8ed1 100644 --- a/packages/ndk_flutter/lib/l10n/app_fi.arb +++ b/packages/ndk_flutter/lib/l10n/app_fi.arb @@ -1,4 +1,90 @@ { + "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", + "walletDetailWalletId": "Lompakon tunnus", + "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", + "walletUnreachable": "Lompakkoa ei tavoiteta", + "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ä?", @@ -383,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", @@ -391,6 +478,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", @@ -430,5 +519,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 20e136525..dbea60d71 100644 --- a/packages/ndk_flutter/lib/l10n/app_fr.arb +++ b/packages/ndk_flutter/lib/l10n/app_fr.arb @@ -1,4 +1,90 @@ { + "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é", + "walletDetailWalletId": "Identifiant du portefeuille", + "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", + "walletUnreachable": "Portefeuille inaccessible", + "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?", @@ -325,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", @@ -333,6 +420,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}", @@ -372,5 +461,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 27934d782..855a61248 100644 --- a/packages/ndk_flutter/lib/l10n/app_it.arb +++ b/packages/ndk_flutter/lib/l10n/app_it.arb @@ -1,4 +1,90 @@ { + "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", + "walletDetailWalletId": "ID portafoglio", + "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", + "walletUnreachable": "Portafoglio non raggiungibile", + "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?", @@ -383,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", @@ -391,6 +478,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}", @@ -430,5 +519,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 b25669554..909a04bde 100644 --- a/packages/ndk_flutter/lib/l10n/app_ja.arb +++ b/packages/ndk_flutter/lib/l10n/app_ja.arb @@ -1,4 +1,90 @@ { + "lnbitsWalletOption": "LNbits", + "lnbitsConnectionInstructions": "LNbitsで接続するウォレットを選んで開き、APIドキュメントを押して管理者キーをコピーしてください。下に貼り付けます:", + "lnbitsAdminKey": "LNbits管理者キー", + "lnbitsKeyType": "LNbitsキーの種類", + "lnbitsInvoiceReadKey": "LNbits請求書・読み取りキー", + "lnbitsReadOnlyDescription": "受信専用ウォレット:残高と履歴の表示、請求書の作成ができます。支払いの送信は無効です。", + "lnbitsUrl": "LNbits URL", + "lnbitsCredentialsRequired": "LNbits管理者キーとURLを入力してください。", + "lnbitsWalletAdded": "LNbitsウォレットを追加しました", + "walletDetailWalletId": "ウォレットID", + "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": "再試行", + "walletUnreachable": "ウォレットに接続できません", + "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": "初めてですか?", @@ -325,6 +411,7 @@ "connectNwcTitle": "NWCを接続", "chooseNwcMethod": "接続方法を選択", "albyGoOption": "Alby Go", + "albyGoQrScanInstructions": "Alby Goで「送信」をタップして、このQRコードをスキャンしてください。", "manualOption": "手動", "faucetOption": "Faucet", "invalidNwcQrCode": "無効なNWC QRコード", @@ -333,6 +420,8 @@ "scanNwcInstructions": "NWCウォレットアプリからQRコードをスキャンしてください", "invalidNwcUri": "無効なNWC URI", "paste": "貼り付け", + "clearInput": "入力を消去", + "pasteOrEnter": "貼り付けまたは入力", "fromYourProfile": "プロフィールから", "orEnterManually": "または手動で入力:", "budgetUsedOf": "予算: {used} / {total}", @@ -372,5 +461,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 b12e95fa2..c51d537b4 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations.dart @@ -119,6 +119,96 @@ 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 @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: + /// **'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: + /// **'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: @@ -1619,6 +1709,42 @@ 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 @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: + /// **'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: @@ -1991,6 +2117,150 @@ 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; + + /// 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: + /// **'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: @@ -2027,6 +2297,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: @@ -2069,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: @@ -2117,6 +2435,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: @@ -2290,6 +2620,330 @@ 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 @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: + /// **'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; + + /// 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; + + /// 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 a09aa411d..1d13ca92e 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_de.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_de.dart @@ -8,6 +8,56 @@ 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 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'; + + @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'; + + @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'; @@ -771,6 +821,27 @@ 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 noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Rechnung'; @@ -967,6 +1038,90 @@ 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 walletUnreachable => 'Wallet nicht erreichbar'; + + @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'; @@ -987,6 +1142,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'; @@ -1009,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'; @@ -1034,6 +1219,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'; @@ -1135,4 +1326,181 @@ 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-Angebot'; + + @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 => 'BOLT12-Wallet hinzufügen'; + + @override + String get enterBolt12Input => + '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'; + + @override + String get walletNameOptional => 'Wallet-Name (optional)'; + + @override + String get scanBolt12QrCodeTitle => 'BOLT12-QR-Code scannen'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Bitte gib ein BOLT12-Angebot oder eine BIP353-Adresse ein.'; + + @override + String get bolt12WalletAdded => 'BOLT12-Wallet erfolgreich hinzugefügt!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + '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'; + + @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 d5ac75667..8f1e2ee77 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_en.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_en.dart @@ -8,6 +8,54 @@ 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 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'; + + @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'; + + @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'; @@ -770,6 +818,27 @@ 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 noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Invoice'; @@ -965,6 +1034,90 @@ 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 walletUnreachable => 'Wallet unreachable'; + + @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'; @@ -984,6 +1137,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'; @@ -1006,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'; @@ -1030,6 +1211,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'; @@ -1131,4 +1318,180 @@ 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 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'; + + @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.'; + + @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'; + + @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 49f1c9a79..c1fb0fc33 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_es.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_es.dart @@ -8,6 +8,56 @@ 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 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'; + + @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'; + + @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'; @@ -773,6 +823,27 @@ 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 noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Factura'; @@ -969,6 +1040,90 @@ 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 walletUnreachable => 'Cartera inaccesible'; + + @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'; @@ -988,6 +1143,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'; @@ -1010,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'; @@ -1035,6 +1220,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'; @@ -1136,4 +1327,181 @@ class AppLocalizationsEs extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'Cartera BOLT12'; + + @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 => '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 => 'Añadir cartera BOLT12'; + + @override + String get enterBolt12Input => + '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 usuario@dominio.com'; + + @override + String get walletNameOptional => 'Nombre de la cartera (opcional)'; + + @override + String get scanBolt12QrCodeTitle => 'Escanear código QR BOLT12'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Introduce una oferta BOLT12 o una dirección BIP353.'; + + @override + String get bolt12WalletAdded => '¡Cartera BOLT12 añadida correctamente!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + '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'; + + @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 f36e18d78..d7dd821c1 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart @@ -8,6 +8,55 @@ 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 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'; + + @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'; + + @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'; @@ -771,6 +820,27 @@ 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 noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Lasku'; @@ -966,6 +1036,90 @@ 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 walletUnreachable => 'Lompakkoa ei tavoiteta'; + + @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'; @@ -985,6 +1139,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'; @@ -1007,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'; @@ -1032,6 +1215,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'; @@ -1133,4 +1322,182 @@ class AppLocalizationsFi extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'BOLT12-lompakko'; + + @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-tarjous'; + + @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 => 'Lisää BOLT12-lompakko'; + + @override + String get enterBolt12Input => + '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 käyttäjä@verkkotunnus.com'; + + @override + String get walletNameOptional => 'Lompakon nimi (valinnainen)'; + + @override + String get scanBolt12QrCodeTitle => 'Skannaa BOLT12-QR-koodi'; + + @override + String get invalidBolt12QrCode => + '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ätty!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + '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'; + + @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 7dd0b8df0..b4fb23a00 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart @@ -8,6 +8,56 @@ 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 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'; + + @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'; + + @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'; @@ -772,6 +822,27 @@ 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 noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Facture'; @@ -968,6 +1039,90 @@ 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 walletUnreachable => 'Portefeuille inaccessible'; + + @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'; @@ -988,6 +1143,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'; @@ -1010,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'; @@ -1035,6 +1219,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'; @@ -1136,4 +1326,182 @@ class AppLocalizationsFr extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'Portefeuille BOLT12'; + + @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 => 'Offre 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 => 'Ajouter un portefeuille BOLT12'; + + @override + String get enterBolt12Input => + 'Saisissez ou scannez une offre lno, un URI bitcoin:?lno=… ou une adresse BIP353.'; + + @override + String get bolt12Input => 'Cible de paiement BOLT12'; + + @override + String get bolt12InputHint => + 'lno1…, bitcoin:?lno=… ou utilisateur@domaine.com'; + + @override + String get walletNameOptional => 'Nom du portefeuille (facultatif)'; + + @override + String get scanBolt12QrCodeTitle => 'Scanner le QR code BOLT12'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Saisissez une offre BOLT12 ou une adresse BIP353.'; + + @override + String get bolt12WalletAdded => 'Portefeuille BOLT12 ajouté !'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + '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é'; + + @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 865182eb7..d83291f3c 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_it.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_it.dart @@ -8,6 +8,56 @@ 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 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'; + + @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'; + + @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'; @@ -774,6 +824,27 @@ 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 noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Fattura'; @@ -970,6 +1041,90 @@ 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 walletUnreachable => 'Portafoglio non raggiungibile'; + + @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'; @@ -989,6 +1144,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'; @@ -1011,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'; @@ -1036,6 +1221,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'; @@ -1137,4 +1328,181 @@ class AppLocalizationsIt extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'Portafoglio BOLT12'; + + @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 => '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 => 'Aggiungi portafoglio BOLT12'; + + @override + String get enterBolt12Input => + 'Inserisci o scansiona un\'offerta lno, un URI bitcoin:?lno=… o un indirizzo BIP353.'; + + @override + String get bolt12Input => 'Destinazione di pagamento BOLT12'; + + @override + String get bolt12InputHint => 'lno1…, bitcoin:?lno=… o utente@dominio.com'; + + @override + String get walletNameOptional => 'Nome del portafoglio (facoltativo)'; + + @override + String get scanBolt12QrCodeTitle => 'Scansiona codice QR BOLT12'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Inserisci un\'offerta BOLT12 o un indirizzo BIP353.'; + + @override + String get bolt12WalletAdded => 'Portafoglio BOLT12 aggiunto!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @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'; + + @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 ea6e13eb9..266944071 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart @@ -8,6 +8,53 @@ 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 lnbitsKeyType => 'LNbitsキーの種類'; + + @override + String get lnbitsInvoiceReadKey => 'LNbits請求書・読み取りキー'; + + @override + String get lnbitsReadOnlyDescription => + '受信専用ウォレット:残高と履歴の表示、請求書の作成ができます。支払いの送信は無効です。'; + + @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 => 'バックアップをファイルに保存'; + + @override + String get backupSavedToFile => 'バックアップをファイルに保存しました'; + + @override + String get restoreFromFile => 'ファイルから復元'; + + @override + String get backupFileReadFailed => '選択したバックアップファイルを読み込めませんでした。'; + + @override + String get fetchingWalletConnectionInfo => 'ウォレットの接続情報を取得中…'; + @override String get createAccount => 'アカウントを作成'; @@ -764,6 +811,27 @@ 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 noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => '請求書'; @@ -958,6 +1026,88 @@ 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 walletUnreachable => 'ウォレットに接続できません'; + + @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 => 'ウォレットタイプを選択'; @@ -976,6 +1126,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ウォレットを使う'; @@ -997,6 +1171,10 @@ class AppLocalizationsJa extends AppLocalizations { @override String get albyGoOption => 'Alby Go'; + @override + String get albyGoQrScanInstructions => + 'Alby Goで「送信」をタップして、このQRコードをスキャンしてください。'; + @override String get manualOption => '手動'; @@ -1021,6 +1199,12 @@ class AppLocalizationsJa extends AppLocalizations { @override String get paste => '貼り付け'; + @override + String get clearInput => '入力を消去'; + + @override + String get pasteOrEnter => '貼り付けまたは入力'; + @override String get fromYourProfile => 'プロフィールから'; @@ -1122,4 +1306,178 @@ class AppLocalizationsJa extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'BOLT12ウォレット'; + + @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オファー'; + + @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 => 'BOLT12ウォレットを追加'; + + @override + String get enterBolt12Input => + 'lnoオファー、bitcoin:?lno=… URI、またはBIP353アドレスを入力またはスキャンしてください。'; + + @override + String get bolt12Input => 'BOLT12支払い先'; + + @override + String get bolt12InputHint => 'lno1…、bitcoin:?lno=…、またはuser@domain.com'; + + @override + String get walletNameOptional => 'ウォレット名(任意)'; + + @override + 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 => 'BOLT12オファーまたはBIP353アドレスを入力してください。'; + + @override + String get bolt12WalletAdded => 'BOLT12ウォレットを追加しました!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @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 => '最近のコミュニティレビュー'; + + @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 6820fc488..a47b82721 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart @@ -8,6 +8,56 @@ 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 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'; + + @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'; + + @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'; @@ -774,6 +824,27 @@ 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 noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Faktura'; @@ -969,6 +1040,90 @@ 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 walletUnreachable => 'Portfel jest nieosiągalny'; + + @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'; @@ -988,6 +1143,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'; @@ -1010,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'; @@ -1034,6 +1219,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'; @@ -1135,4 +1326,181 @@ class AppLocalizationsPl extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'Portfel BOLT12'; + + @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 => '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 => 'Dodaj portfel BOLT12'; + + @override + String get enterBolt12Input => + 'Wprowadź lub zeskanuj ofertę lno, URI bitcoin:?lno=… albo adres BIP353.'; + + @override + String get bolt12Input => 'Cel płatności BOLT12'; + + @override + String get bolt12InputHint => + 'lno1…, bitcoin:?lno=… lub użytkownik@domena.com'; + + @override + String get walletNameOptional => 'Nazwa portfela (opcjonalna)'; + + @override + String get scanBolt12QrCodeTitle => 'Skanuj kod QR BOLT12'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Wprowadź ofertę BOLT12 lub adres BIP353.'; + + @override + String get bolt12WalletAdded => 'Dodano portfel BOLT12!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @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'; + + @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 6f3f422b8..28d73f6d3 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart @@ -8,6 +8,56 @@ 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 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'; + + @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'; + + @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'; @@ -775,6 +825,27 @@ 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 noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Fatura'; @@ -972,6 +1043,90 @@ 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 walletUnreachable => 'Carteira inacessível'; + + @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'; @@ -991,6 +1146,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'; @@ -1013,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'; @@ -1038,6 +1223,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'; @@ -1139,12 +1330,240 @@ class AppLocalizationsPt extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'Carteira BOLT12'; + + @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 => '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 => 'Adicionar carteira BOLT12'; + + @override + String get enterBolt12Input => + 'Introduza ou digitalize 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 utilizador@dominio.com'; + + @override + String get walletNameOptional => 'Nome da carteira (opcional)'; + + @override + String get scanBolt12QrCodeTitle => 'Digitalizar código QR BOLT12'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Introduza uma oferta BOLT12 ou um endereço BIP353.'; + + @override + String get bolt12WalletAdded => 'Carteira BOLT12 adicionada!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @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'; + + @override + String get refreshBalance => 'Atualizar saldo'; + + @override + String get balanceRefreshed => 'Saldo atualizado'; } /// The translations for Portuguese, as used in Brazil (`pt_BR`). 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 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'; + + @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'; + + @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'; @@ -2108,6 +2527,90 @@ 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 walletUnreachable => 'Carteira inacessível'; + + @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'; @@ -2127,6 +2630,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'; @@ -2149,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'; @@ -2174,6 +2707,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'; @@ -2233,4 +2772,134 @@ 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'; + + @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 e65906eb2..01aebcd3b 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart @@ -8,6 +8,56 @@ 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 lnbitsKeyType => 'Тип ключа LNbits'; + + @override + String get lnbitsInvoiceReadKey => 'Ключ счетов/чтения LNbits'; + + @override + String get lnbitsReadOnlyDescription => + 'Кошелёк только для получения: просмотр баланса и истории, создание счетов. Отправка платежей отключена.'; + + @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 => 'Сохранить резервную копию в файл'; + + @override + String get backupSavedToFile => 'Резервная копия сохранена в файл'; + + @override + String get restoreFromFile => 'Восстановить из файла'; + + @override + String get backupFileReadFailed => + 'Не удалось прочитать выбранный файл резервной копии.'; + + @override + String get fetchingWalletConnectionInfo => + 'Получение данных подключения кошелька…'; + @override String get createAccount => 'Создать аккаунт'; @@ -770,6 +820,27 @@ 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 noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Счёт'; @@ -966,6 +1037,90 @@ 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 walletUnreachable => 'Кошелёк недоступен'; + + @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 => 'Выберите тип кошелька'; @@ -986,6 +1141,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'; @@ -1008,6 +1189,10 @@ class AppLocalizationsRu extends AppLocalizations { @override String get albyGoOption => 'Alby Go'; + @override + String get albyGoQrScanInstructions => + 'В Alby Go нажмите «Отправить», затем отсканируйте этот QR-код.'; + @override String get manualOption => 'Вручную'; @@ -1033,6 +1218,12 @@ class AppLocalizationsRu extends AppLocalizations { @override String get paste => 'Вставить'; + @override + String get clearInput => 'Очистить поле'; + + @override + String get pasteOrEnter => 'Вставить или ввести'; + @override String get fromYourProfile => 'Из вашего профиля'; @@ -1134,4 +1325,181 @@ class AppLocalizationsRu extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'Кошелёк BOLT12'; + + @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'; + + @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 => 'Добавить кошелёк BOLT12'; + + @override + String get enterBolt12Input => + 'Введите или отсканируйте предложение lno, URI bitcoin:?lno=… или адрес BIP353.'; + + @override + String get bolt12Input => 'Цель платежа BOLT12'; + + @override + String get bolt12InputHint => + 'lno1…, bitcoin:?lno=… или пользователь@домен.com'; + + @override + String get walletNameOptional => 'Название кошелька (необязательно)'; + + @override + String get scanBolt12QrCodeTitle => 'Сканировать QR-код BOLT12'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Введите предложение BOLT12 или адрес BIP353.'; + + @override + String get bolt12WalletAdded => 'Кошелёк BOLT12 успешно добавлен!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @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 => 'Недавние отзывы сообщества'; + + @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 82581ca95..b53bd9457 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart @@ -8,6 +8,55 @@ 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 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'; + + @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'; + + @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'; @@ -771,6 +820,27 @@ 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 noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Faktúra'; @@ -966,6 +1036,90 @@ 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 walletUnreachable => 'Peňaženka je nedostupná'; + + @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'; @@ -985,6 +1139,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'; @@ -1007,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'; @@ -1032,6 +1215,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'; @@ -1133,4 +1322,181 @@ class AppLocalizationsSk extends AppLocalizations { String restoreSuccess(int count) { return 'Obnovených $count dôkazov zo zálohy'; } + + @override + String get bolt12Wallet => 'Peňaženka BOLT12'; + + @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 => '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 => 'Pridať peňaženku BOLT12'; + + @override + String get enterBolt12Input => + 'Zadajte alebo naskenujte ponuku lno, URI bitcoin:?lno=… alebo adresu BIP353.'; + + @override + String get bolt12Input => 'Platobný cieľ BOLT12'; + + @override + String get bolt12InputHint => + 'lno1…, bitcoin:?lno=… alebo používateľ@doména.com'; + + @override + String get walletNameOptional => 'Názov peňaženky (voliteľné)'; + + @override + String get scanBolt12QrCodeTitle => 'Naskenovať QR kód BOLT12'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Zadajte ponuku BOLT12 alebo adresu BIP353.'; + + @override + String get bolt12WalletAdded => 'Peňaženka BOLT12 bola pridaná!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @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'; + + @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 cb51dea18..f935b9c8a 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart @@ -8,6 +8,52 @@ 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 lnbitsKeyType => 'LNbits 密钥类型'; + + @override + String get lnbitsInvoiceReadKey => 'LNbits 发票/只读密钥'; + + @override + String get lnbitsReadOnlyDescription => '仅收款钱包:可查看余额和历史记录并创建发票,无法发送付款。'; + + @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 => '将备份保存到文件'; + + @override + String get backupSavedToFile => '备份已保存到文件'; + + @override + String get restoreFromFile => '从文件恢复'; + + @override + String get backupFileReadFailed => '无法读取所选备份文件。'; + + @override + String get fetchingWalletConnectionInfo => '正在获取钱包连接信息…'; + @override String get createAccount => '创建账户'; @@ -764,6 +810,27 @@ 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 noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => '发票'; @@ -957,6 +1024,87 @@ 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 walletUnreachable => '无法连接钱包'; + + @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 => '选择钱包类型'; @@ -975,6 +1123,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 钱包'; @@ -996,6 +1167,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get albyGoOption => 'Alby Go'; + @override + String get albyGoQrScanInstructions => '在 Alby Go 中点击“发送”,然后扫描此二维码。'; + @override String get manualOption => '手动'; @@ -1020,6 +1194,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get paste => '粘贴'; + @override + String get clearInput => '清除输入'; + + @override + String get pasteOrEnter => '粘贴或输入'; + @override String get fromYourProfile => '来自您的个人资料'; @@ -1121,4 +1301,177 @@ class AppLocalizationsZh extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'BOLT12 钱包'; + + @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 报价'; + + @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 => '添加 BOLT12 钱包'; + + @override + String get enterBolt12Input => '输入或扫描 lno 报价、bitcoin:?lno=… URI 或 BIP353 地址。'; + + @override + String get bolt12Input => 'BOLT12 支付目标'; + + @override + String get bolt12InputHint => 'lno1…、bitcoin:?lno=… 或 user@domain.com'; + + @override + String get walletNameOptional => '钱包名称(可选)'; + + @override + String get scanBolt12QrCodeTitle => '扫描 BOLT12 二维码'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => '请输入 BOLT12 报价或 BIP353 地址。'; + + @override + String get bolt12WalletAdded => 'BOLT12 钱包添加成功!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @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 => '近期社区评论'; + + @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 240bf2f96..da8f69bf8 100644 --- a/packages/ndk_flutter/lib/l10n/app_pl.arb +++ b/packages/ndk_flutter/lib/l10n/app_pl.arb @@ -1,4 +1,90 @@ { + "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", + "walletDetailWalletId": "Identyfikator portfela", + "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", + "walletUnreachable": "Portfel jest nieosiągalny", + "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?", @@ -383,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", @@ -391,6 +478,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}", @@ -430,5 +519,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 478efb64c..14b8fbe83 100644 --- a/packages/ndk_flutter/lib/l10n/app_pt.arb +++ b/packages/ndk_flutter/lib/l10n/app_pt.arb @@ -1,4 +1,90 @@ { + "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", + "walletDetailWalletId": "ID da carteira", + "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", + "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", + "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?", @@ -325,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", @@ -333,6 +420,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", @@ -348,5 +437,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 8efc22f8c..d8bedb9a2 100644 --- a/packages/ndk_flutter/lib/l10n/app_pt_BR.arb +++ b/packages/ndk_flutter/lib/l10n/app_pt_BR.arb @@ -1,4 +1,90 @@ { + "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", + "walletDetailWalletId": "ID da carteira", + "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", + "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", + "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?", @@ -325,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", @@ -333,6 +420,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", @@ -348,5 +437,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 70695143e..206bed561 100644 --- a/packages/ndk_flutter/lib/l10n/app_ru.arb +++ b/packages/ndk_flutter/lib/l10n/app_ru.arb @@ -1,4 +1,90 @@ { + "lnbitsWalletOption": "LNbits", + "lnbitsConnectionInstructions": "В LNbits выберите кошелёк для подключения, откройте его, нажмите «Документация API» и скопируйте ключ администратора. Вставьте его ниже:", + "lnbitsAdminKey": "Ключ администратора LNbits", + "lnbitsKeyType": "Тип ключа LNbits", + "lnbitsInvoiceReadKey": "Ключ счетов/чтения LNbits", + "lnbitsReadOnlyDescription": "Кошелёк только для получения: просмотр баланса и истории, создание счетов. Отправка платежей отключена.", + "lnbitsUrl": "URL LNbits", + "lnbitsCredentialsRequired": "Введите ключ администратора и URL LNbits.", + "lnbitsWalletAdded": "Кошелёк LNbits добавлен", + "walletDetailWalletId": "ID кошелька", + "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": "Повторить", + "walletUnreachable": "Кошелёк недоступен", + "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": "Вы здесь новенький?", @@ -325,6 +411,7 @@ "connectNwcTitle": "Подключить NWC", "chooseNwcMethod": "Выберите способ подключения", "albyGoOption": "Alby Go", + "albyGoQrScanInstructions": "В Alby Go нажмите «Отправить», затем отсканируйте этот QR-код.", "manualOption": "Вручную", "faucetOption": "Кран", "invalidNwcQrCode": "Неверный QR-код NWC", @@ -333,6 +420,8 @@ "scanNwcInstructions": "Отсканируйте QR-код из приложения кошелька NWC", "invalidNwcUri": "Неверный URI NWC", "paste": "Вставить", + "clearInput": "Очистить поле", + "pasteOrEnter": "Вставить или ввести", "fromYourProfile": "Из вашего профиля", "orEnterManually": "Или введите вручную:", "budgetUsedOf": "Бюджет: {used} / {total}", @@ -372,5 +461,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 091528a1b..d29dd8b3c 100644 --- a/packages/ndk_flutter/lib/l10n/app_sk.arb +++ b/packages/ndk_flutter/lib/l10n/app_sk.arb @@ -1,4 +1,90 @@ { + "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á", + "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", + "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", + "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", + "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?", @@ -383,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", @@ -391,6 +478,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ť", @@ -452,5 +541,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 5c2d72e2b..c54ad6ae8 100644 --- a/packages/ndk_flutter/lib/l10n/app_zh.arb +++ b/packages/ndk_flutter/lib/l10n/app_zh.arb @@ -1,4 +1,90 @@ { + "lnbitsWalletOption": "LNbits", + "lnbitsConnectionInstructions": "在 LNbits 中选择并打开要连接的钱包,点击 API 文档并复制管理员密钥。粘贴到下方:", + "lnbitsAdminKey": "LNbits 管理员密钥", + "lnbitsKeyType": "LNbits 密钥类型", + "lnbitsInvoiceReadKey": "LNbits 发票/只读密钥", + "lnbitsReadOnlyDescription": "仅收款钱包:可查看余额和历史记录并创建发票,无法发送付款。", + "lnbitsUrl": "LNbits URL", + "lnbitsCredentialsRequired": "请输入 LNbits 管理员密钥和 URL。", + "lnbitsWalletAdded": "LNbits 钱包已添加", + "walletDetailWalletId": "钱包 ID", + "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": "重试", + "walletUnreachable": "无法连接钱包", + "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": "您是新用户吗?", @@ -325,6 +411,7 @@ "connectNwcTitle": "连接 NWC", "chooseNwcMethod": "选择连接方式", "albyGoOption": "Alby Go", + "albyGoQrScanInstructions": "在 Alby Go 中点击“发送”,然后扫描此二维码。", "manualOption": "手动", "faucetOption": "水龙头", "invalidNwcQrCode": "无效的NWC二维码", @@ -333,6 +420,8 @@ "scanNwcInstructions": "从您的NWC钱包应用扫描二维码", "invalidNwcUri": "无效的NWC URI", "paste": "粘贴", + "clearInput": "清除输入", + "pasteOrEnter": "粘贴或输入", "fromYourProfile": "来自您的个人资料", "orEnterManually": "或手动输入:", "budgetUsedOf": "预算:{used} / {total}", @@ -372,5 +461,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 f33a164a0..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 @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io' show Platform; import 'package:android_intent_plus/android_intent.dart'; @@ -10,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'; @@ -22,8 +25,404 @@ 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); + +/// 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; + 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; + + const WalletInputScanResult.lnBits(LnBitsConnectionInput connection) + : lnBitsConnection = connection, + value = null, + connectionStarted = false, + manuallyEntered = true, + origin = WalletInputOrigin.walletChooser, + cashuMintSuggestion = null, + providerId = null; + + const WalletInputScanResult.connectionStarted() + : value = null, + connectionStarted = true, + manuallyEntered = false, + origin = WalletInputOrigin.walletChooser, + cashuMintSuggestion = null, + lnBitsConnection = null, + providerId = null; +} + +class LnBitsConnectionInput { + final String url; + 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, + }); +} + +/// 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 Future Function(LnBitsConnectionInput input)? + validateLnBitsConnection; + 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.validateLnBitsConnection, + 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, + }); +} + +/// 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 } + +/// 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) { + var value = input.trim(); + if (value.toLowerCase().startsWith('lightning:')) { + value = value.substring('lightning:'.length).trim(); + } + if (value.toLowerCase().startsWith('bitcoin?')) { + value = 'bitcoin:${value.substring('bitcoin'.length)}'; + } + return value; +} + +/// Builds client-key web-wallet authorization URL. +Uri buildNwcWebWalletAuthUri({ + required Uri authorizationEndpoint, + required String appName, + required String pubkey, + required String state, + Map additionalQueryParameters = const {}, +}) { + return authorizationEndpoint.replace( + queryParameters: { + ...authorizationEndpoint.queryParameters, + ...additionalQueryParameters, + 'name': appName, + 'pubkey': pubkey, + 'state': state, + }, + ); +} + +/// Builds standard NWC wallet-auth URI handled by compatible wallet apps. +Uri buildNwcWalletAuthUri({ + required String appPubkey, + required AlbyGoConnectConfig config, + required String state, + String scheme = 'nostr+walletauth', + bool includeReturnTo = true, +}) { + return Uri( + 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, + 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 { + 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, @@ -32,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. @@ -49,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; @@ -61,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', }); } @@ -76,68 +477,430 @@ class NwcWalletAuthCoordinator { _PendingNwcWalletAuthSession? _pendingSession; _PendingNwcCallbackSession? _pendingCallbackSession; String? _lastConnectedWalletId; + bool _isCompletingPendingSession = false; + Future Function()? _retryLaunch; + Future Function()? _closeWalletAuthSubscription; + final ValueNotifier connectionState = ValueNotifier( + const WalletConnectionState.idle(), + ); 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. + /// 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, + String? providerId, }) async { - if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) return; + _retryLaunch = () => connectWithUri( + context, + launchUri: launchUri, + callback: callback, + walletName: walletName, + providerId: providerId, + ); + _pendingSession = null; + _pendingCallbackSession = _PendingNwcCallbackSession( + returnTo: callback, + walletName: walletName, + providerId: providerId, + ); + _markAwaiting(walletName); + + try { + if (!kIsWeb && Platform.isAndroid) { + final intent = AndroidIntent( + action: 'action_view', + data: launchUri.toString(), + ); + await intent.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 an NWC-07 URI using normal platform intent resolution. + Future connectInstalledWallet( + BuildContext context, { + required AlbyGoConnectConfig config, + }) { + return connectWithUri( + context, + launchUri: buildNwcCallbackUri(config: config), + callback: config.callback, + walletName: 'NWC', + ); + } + + /// Opens standard `nostr+walletauth://` URI in a compatible wallet. + Future connectWalletAuth( + BuildContext context, { + required AlbyGoConnectConfig config, + required String walletName, + String? providerId, + String uriScheme = 'nostr+walletauth', + String? androidPackage, + NdkFlutter? qrFallbackNdkFlutter, + bool showAlbyGoQrInstructions = false, + }) async { + final canLaunchWalletApp = + !kIsWeb && (Platform.isAndroid || Platform.isIOS); + if (!canLaunchWalletApp && qrFallbackNdkFlutter == null) 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, + 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( + appKey: appKey, + discoveryRelay: config.discoveryRelay, + 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; + } - _pendingSession = _PendingNwcWalletAuthSession( - appKey: appKey, - discoveryRelay: config.discoveryRelay, - returnTo: config.callback, - walletName: config.walletName, + try { + if (Platform.isAndroid) { + final intent = AndroidIntent( + action: 'action_view', + data: launchUri.toString(), + 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, + mode: LaunchMode.externalApplication, + ); + 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; + 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, - }, + } + } + + 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`. + /// 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? 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, + appName: appName, + discoveryRelay: discoveryRelay, + callback: callback, + 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, + ); + + _pendingSession = _PendingNwcWalletAuthSession( + appKey: appKey, + discoveryRelay: discoveryRelay, + returnTo: callback, + walletName: walletName, + walletServicePubkey: walletServicePubkey, + providerId: providerId, + state: state, + allowUntaggedInfoEvent: allowUntaggedInfoEvent, + ); + _pendingCallbackSession = null; + _markAwaiting(walletName); + + try { + final launched = await launchUrl( + launchUri, + 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; - _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 (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: config.nostrNwcScheme, + host: config.nostrNwcHost, + queryParameters: { + 'appname': config.appName, + 'appicon': config.appIconUrl, + 'callback': config.callback, + }, + ); + _pendingSession = null; + _pendingCallbackSession = _PendingNwcCallbackSession( + returnTo: config.callback, + walletName: config.walletName, + providerId: 'alby', + ); + _markAwaiting(config.walletName); + try { if (Platform.isAndroid) { final intent = AndroidIntent( action: 'action_view', data: launchUri.toString(), + package: config.androidPackage, ); await intent.launch(); } else { @@ -150,7 +913,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( @@ -172,16 +936,78 @@ class NwcWalletAuthCoordinator { ? ScaffoldMessenger.of(context) : null; - final callbackNwcUri = _extractNwcUriFromCallback(url); - if (callbackNwcUri != null) { - final pendingCallbackSession = _pendingCallbackSession; - if (pendingCallbackSession != null && - !url.startsWith(Nwc.kNWCProtocolPrefix) && - !_matchesReturnTo(url, pendingCallbackSession.returnTo)) { - return false; - } + final returnedUri = Uri.tryParse(url); + final pendingWalletAuth = _pendingSession; + 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 && + 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, + providerId: pendingWalletAuth.providerId, + ); + _pendingSession = null; + final closeSubscription = _closeWalletAuthSubscription; + _closeWalletAuthSubscription = null; + await closeSubscription?.call(); + 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; + if (pendingCallbackSession != null && + !url.startsWith(Nwc.kNWCProtocolPrefix) && + !_matchesReturnTo(url, pendingCallbackSession.returnTo)) { + return false; + } try { + connectionState.value = WalletConnectionState.connecting( + pendingCallbackSession?.walletName ?? + _pendingSession?.walletName ?? + kDefaultAlbyGoConnectConfig.walletName, + ); await _addNwcWallet( ndkFlutter, nwcUri: callbackNwcUri, @@ -189,7 +1015,19 @@ class NwcWalletAuthCoordinator { pendingCallbackSession?.walletName ?? _pendingSession?.walletName ?? kDefaultAlbyGoConnectConfig.walletName, + providerId: + pendingCallbackSession?.providerId ?? _pendingSession?.providerId, ); + _pendingSession = null; + final closeSubscription = _closeWalletAuthSubscription; + _closeWalletAuthSubscription = null; + await closeSubscription?.call(); + connectionState.value = WalletConnectionState.connected( + pendingCallbackSession?.walletName ?? + _pendingSession?.walletName ?? + kDefaultAlbyGoConnectConfig.walletName, + ); + _retryLaunch = null; if (context.mounted) { scaffoldMessenger!.showSnackBar( SnackBar( @@ -199,6 +1037,12 @@ class NwcWalletAuthCoordinator { ); } } catch (e) { + _markFailed( + pendingCallbackSession?.walletName ?? + _pendingSession?.walletName ?? + kDefaultAlbyGoConnectConfig.walletName, + e, + ); if (context.mounted) { scaffoldMessenger!.showSnackBar( SnackBar( @@ -220,33 +1064,185 @@ 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, { + Duration? timeout = const Duration(seconds: 15), + bool showMessages = true, + }) 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) { + if (showMessages && context.mounted) { scaffoldMessenger!.showSnackBar( - const SnackBar( - content: Text( - 'Wallet callback received. Fetching connection info...', - ), - ), + SnackBar(content: Text(l10n!.fetchingWalletConnectionInfo)), ); } + Future Function()? closeSubscription; try { - final stream = ndkFlutter.ndk.requests - .query( - filter: Filter( - kinds: [NwcKind.INFO.value], - pTags: [pendingSession.appKey.publicKey], - 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', + ); + } - final Nip01Event foundWalletAuthEvent = await stream.first; + 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 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) { @@ -255,16 +1251,32 @@ class NwcWalletAuthCoordinator { ); } + final walletServicePubkey = + pendingSession.walletServicePubkey ?? foundWalletAuthEvent.pubKey; + final connectionRelay = walletAuthConnectionRelay( + foundWalletAuthEvent, + fallbackRelay: pendingSession.discoveryRelay, + ); final constructedNwcUri = - 'nostr+walletconnect://${foundWalletAuthEvent.pubKey}?relay=${Uri.encodeComponent(pendingSession.discoveryRelay)}&secret=$appPrivateKey'; + 'nostr+walletconnect://$walletServicePubkey?relay=${Uri.encodeComponent(connectionRelay)}&secret=$appPrivateKey'; - await _addNwcWallet( - ndkFlutter, - nwcUri: constructedNwcUri, - walletName: pendingSession.walletName, + if (!walletAddedDuringDiscovery) { + await _addNwcWallet( + ndkFlutter, + nwcUri: constructedNwcUri, + walletName: pendingSession.walletName, + providerId: pendingSession.providerId, + requireAuthenticatedResponse: pendingSession.allowUntaggedInfoEvent, + ); + } + + _pendingSession = null; + connectionState.value = WalletConnectionState.connected( + pendingSession.walletName, ); + _retryLaunch = null; - if (!context.mounted) return true; + if (!showMessages || !context.mounted) return true; scaffoldMessenger!.showSnackBar( SnackBar( content: Text(l10n!.nwcWalletAdded), @@ -273,7 +1285,12 @@ class NwcWalletAuthCoordinator { ); return true; } on TimeoutException { - if (!context.mounted) return true; + if (!identical(_pendingSession, pendingSession)) return false; + _markFailed( + pendingSession.walletName, + 'Timed out while waiting for wallet connection info from ${pendingSession.discoveryRelay}', + ); + if (!showMessages || !context.mounted) return true; scaffoldMessenger!.showSnackBar( SnackBar( content: Text( @@ -286,7 +1303,9 @@ class NwcWalletAuthCoordinator { ); return true; } catch (e) { - if (!context.mounted) return true; + if (!identical(_pendingSession, pendingSession)) return false; + _markFailed(pendingSession.walletName, e); + if (!showMessages || !context.mounted) return true; scaffoldMessenger!.showSnackBar( SnackBar( content: Text(l10n!.error(e.toString())), @@ -294,6 +1313,12 @@ class NwcWalletAuthCoordinator { ), ); return true; + } finally { + if (identical(_closeWalletAuthSubscription, closeSubscription)) { + _closeWalletAuthSubscription = null; + } + await closeSubscription?.call(); + _isCompletingPendingSession = false; } } @@ -301,6 +1326,8 @@ class NwcWalletAuthCoordinator { NdkFlutter ndkFlutter, { required String nwcUri, required String walletName, + String? providerId, + bool requireAuthenticatedResponse = false, }) async { final walletId = DateTime.now().millisecondsSinceEpoch.toString(); final nwcWallet = NwcWallet( @@ -308,6 +1335,11 @@ class NwcWalletAuthCoordinator { name: walletName, supportedUnits: {'sat'}, nwcUrl: nwcUri, + providerId: providerId, + metadata: { + if (requireAuthenticatedResponse) + NwcWallet.kRequireAuthenticatedResponseMetadataKey: true, + }, ); await ndkFlutter.ndk.wallets.addWallet(nwcWallet); _lastConnectedWalletId = walletId; @@ -319,22 +1351,189 @@ 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; + final String? providerId; const _PendingNwcCallbackSession({ required this.returnTo, required this.walletName, + this.providerId, }); } @@ -962,126 +2161,1446 @@ class _AddLnurlWalletDialogState extends State<_AddLnurlWalletDialog> { onPressed: () => Navigator.of(context).pop(), child: Text(widget.l10n.cancel), ), - TextButton(onPressed: _addManualWallet, child: Text(widget.l10n.add)), + TextButton(onPressed: _addManualWallet, child: Text(widget.l10n.add)), + ], + ); + } +} + +/// 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 the unified add-wallet flow. +/// +/// 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, + 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) => _AddWalletFlow( + ndkFlutter: ndkFlutter, + parentContext: context, + albyGoConnectConfig: albyGoConnectConfig, + nwcWalletAuthCoordinator: coordinator, + walletInputScanner: scanner, + nwcConnectionOptions: + nwcConnectionOptions ?? + defaultNwcConnectionOptions(config: albyGoConnectConfig), + ), + ) ?? + false; +} + +class _AddWalletFlow extends StatefulWidget { + final NdkFlutter ndkFlutter; + final BuildContext parentContext; + final AlbyGoConnectConfig albyGoConnectConfig; + final NwcWalletAuthCoordinator nwcWalletAuthCoordinator; + final WalletInputScanner walletInputScanner; + final List nwcConnectionOptions; + + const _AddWalletFlow({ + required this.ndkFlutter, + required this.parentContext, + required this.albyGoConnectConfig, + required this.nwcWalletAuthCoordinator, + required this.walletInputScanner, + required this.nwcConnectionOptions, + }); + + @override + State<_AddWalletFlow> createState() => _AddWalletFlowState(); +} + +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; + final LnBitsConnectionInput? lnBitsConnection; + final String? providerId; + + 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, + this.lnBitsConnection, + this.providerId, + }); +} + +class _WalletPreviewDetail { + final String label; + final String value; + + const _WalletPreviewDetail(this.label, this.value); +} + +class _AddWalletFlowState extends State<_AddWalletFlow> { + final _inputController = TextEditingController(); + final _walletNameController = TextEditingController(); + final _lnBitsUrlController = TextEditingController(); + final _lnBitsAdminKeyController = TextEditingController(); + WalletInputKind? _inputKind; + String? _errorMessage; + bool _isAdding = false; + bool _isResolvingDetails = false; + _WalletInputPreview? _preview; + bool _scannerOpen = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _scan(); + }); + } + + @override + void dispose() { + _inputController.dispose(); + _walletNameController.dispose(); + _lnBitsUrlController.dispose(); + _lnBitsAdminKeyController.dispose(); + super.dispose(); + } + + Future _scan({ + WalletInputOrigin initialOrigin = WalletInputOrigin.scanner, + }) async { + if (_scannerOpen) return; + setState(() => _scannerOpen = true); + WalletInputScanResult? result; + try { + result = await widget.walletInputScanner( + context, + _scannerConfiguration( + openWalletChooserInitially: + initialOrigin != WalletInputOrigin.scanner, + openCashuMintChooserInitially: + initialOrigin == WalletInputOrigin.cashuMintChooser, + ), + ); + } 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, + ); + } + } + + 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 { + 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( + validated.readOnly + ? l10n.lnbitsInvoiceReadKey + : 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) _closeWithError(error); + } 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, + readOnly: connection.readOnly, + ); + } + + void _cancelPreview() { + final origin = _preview?.origin ?? WalletInputOrigin.scanner; + setState(() { + _preview = null; + _errorMessage = 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.add( + WalletScannerConnectionOption( + id: 'installed-wallet', + label: l10n.chooseWalletApp, + subtitle: l10n.chooseWalletAppDescription, + kind: WalletScannerConnectionKind.installedWallet, + iconBuilder: (_) => const Icon(Icons.account_balance_wallet_outlined), + connect: _launchInstalledWallet, + ), + ); + } + 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( + 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, + validateLnBitsConnection: _validateLnBitsConnection, + 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, + ); + } + + 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, + String? providerId, + }) async { + final input = _normalizeWalletInput(rawInput); + _setInput(input); + final kind = classifyWalletInput(input); + 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; + _errorMessage = null; + }); + + try { + final preview = await _resolvePreview( + input, + kind, + manuallyEntered, + origin, + cashuMintSuggestion, + providerId, + ); + if (!mounted) return; + setState(() { + _preview = preview; + _walletNameController.text = preview.name; + _walletNameController.selection = TextSelection.collapsed( + offset: preview.name.length, + ); + }); + if (kDebugMode) { + debugPrint('[wallet-scan] confirmation ready: ${kind.name}'); + } + } catch (error) { + if (!mounted) return; + if (kDebugMode) { + debugPrint('[wallet-scan] preview failed: ${error.runtimeType}'); + } + _closeWithError(error); + } finally { + if (mounted) setState(() => _isResolvingDetails = false); + } + } + + Future<_WalletInputPreview> _resolvePreview( + String input, + WalletInputKind kind, + bool manuallyEntered, + WalletInputOrigin origin, + CashuMintSuggestion? cashuMintSuggestion, + String? providerId, + ) 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, + providerId: providerId, + 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(), + ), + ], + ); + case WalletInputKind.lnBits: + throw StateError('LNbits uses structured connection details'); + } + } + + _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), ], ); } -} -/// Shows a dialog to choose wallet type (Cashu, NWC, or LNURL). -/// -/// Returns true if a wallet type was selected, false if cancelled. -/// Use [albyGoConnectConfig] to override Alby Go app metadata. -Future showAddWalletTypeDialog( - BuildContext context, - NdkFlutter ndkFlutter, { - AlbyGoConnectConfig albyGoConnectConfig = kDefaultAlbyGoConnectConfig, - NwcWalletAuthCoordinator? nwcWalletAuthCoordinator, - NwcUriScanner? nwcUriScanner, -}) async { - final l10n = AppLocalizations.of(context)!; + Future _confirmInput() async { + var preview = _preview; + if (preview == null) return; - return await showDialog( - context: context, - builder: (dialogContext) => Dialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + 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; + }); + return; + } + + setState(() { + _isAdding = true; + _errorMessage = null; + _inputKind = kind; + }); + + try { + if (isLnBits) { + final connection = await _validateLnBitsConnection( + LnBitsConnectionInput( + url: input, + adminKey: _lnBitsAdminKeyController.text.trim(), + readOnly: preview.lnBitsConnection?.readOnly ?? false, ), - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, + ); + 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, + true, + preview.origin, + preview.cashuMintSuggestion, + preview.providerId, + ); + 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, + providerId: preview.providerId, + ); + 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, + ); + 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.readOnlyMetadataKey: connection.readOnly, + LnBitsWallet.remoteWalletIdMetadataKey: ?connection.remoteWalletId, + }, + ); + await widget.ndkFlutter.ndk.wallets.addWallet(wallet); + return wallet; + } + } + + 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, + WalletType.LNBITS => l10n.lnbitsWalletAdded, + }; + ScaffoldMessenger.of(widget.parentContext).showSnackBar( + SnackBar(content: Text(message), backgroundColor: Colors.green), + ); + } + + 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 _launchInstalledWallet() async { + await widget.nwcWalletAuthCoordinator.connectInstalledWallet( + widget.parentContext, + config: widget.albyGoConnectConfig, + ); + } + + Future _launchAlbyGo() async { + await widget.nwcWalletAuthCoordinator.connectAlbyGo( + widget.parentContext, + widget.ndkFlutter, + config: widget.albyGoConnectConfig, + ); + } + + 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, + WalletType.LNBITS => Icons.bolt, + }; + 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: [ - const SizedBox(width: 24), - Expanded( - child: Text( - l10n.addWalletTitle, - style: Theme.of(context).textTheme.titleLarge, - textAlign: TextAlign.center, + Center( + 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( + l10n.confirmWalletTitle, + style: theme.textTheme.headlineSmall, + ), + const SizedBox(height: 6), + Text( + l10n.confirmWalletDescription, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, ), ), - GestureDetector( - onTap: () => Navigator.of(dialogContext).pop(false), - child: const Icon(Icons.close, size: 24), + 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, + obscureText: true, + enableSuggestions: false, + autocorrect: false, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: preview.lnBitsConnection?.readOnly == true + ? l10n.lnbitsInvoiceReadKey + : 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, + 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, + ), + ), + ], ], ), - const SizedBox(height: 8), - Text( - l10n.chooseWalletType, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Colors.grey), - textAlign: TextAlign.center, - ), - const SizedBox(height: 24), - Column( - mainAxisSize: MainAxisSize.min, + ), + ), + DecoratedBox( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: colors.outlineVariant)), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 20), + child: Row( 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, - ); - }, + Expanded( + child: OutlinedButton( + onPressed: _isAdding ? null : _cancelPreview, + child: Text(l10n.cancel), + ), ), - 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: 12), + Expanded( + child: FilledButton( + onPressed: _isAdding ? null : _confirmInput, + child: _isAdding + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Text(l10n.confirm), + ), ), ], ), - ], + ), ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final preview = _preview; + if (preview != null) { + return _buildConfirmationDialog(context, preview); + } + if (_isResolvingDetails) { + return const Dialog( + child: Padding( + padding: EdgeInsets.all(32), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(width: 20), + Text('Loading wallet details…'), + ], ), ), - ) ?? - false; + ); + } + return const SizedBox.shrink(); + } } /// Shows a dialog to choose NWC connection method. @@ -1283,119 +3802,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..d88ad9481 --- /dev/null +++ b/packages/ndk_flutter/lib/widgets/wallets/n_cashu_mint_icon.dart @@ -0,0 +1,50 @@ +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', + fit: BoxFit.contain, + errorBuilder: (_, _, _) => + const Icon(Icons.account_balance_wallet, color: Colors.orange), + ); + } + + @override + Widget build(BuildContext context) { + final iconUrl = wallet.mintInfo.iconUrl?.trim(); + 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(), + ), + ); + } +} 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_nwc_wallet_icon.dart b/packages/ndk_flutter/lib/widgets/wallets/n_nwc_wallet_icon.dart new file mode 100644 index 000000000..fb15f05bc --- /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.white, + 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 a2d9696b8..e983308fc 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,8 @@ import 'package:ndk/entities.dart'; 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. @@ -46,9 +48,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,15 +78,26 @@ 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 canSend = wallet.canSend; - final bool canReceive = wallet.canReceive; + 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; + final bool canReceive = selectedWallet.canReceive; final bool condensed = widget.condensed; final bool showHeader = widget.showTitle || widget.showCloseButton; final double buttonPadding = condensed ? 8 : 16; @@ -79,28 +111,13 @@ 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', - 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) + const NLnBitsIcon(size: 24) else const Icon(Icons.bolt, color: Colors.purple), const SizedBox(width: 8), @@ -109,6 +126,10 @@ class _NWalletActionsState extends State ? l10n.cashuWallet : isNwc ? l10n.nwcWallet + : isBolt12 + ? l10n.bolt12Wallet + : isLnBits + ? l10n.lnbitsWalletOption : l10n.lnurlWallet, style: Theme.of(context).textTheme.titleMedium, ), @@ -124,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, @@ -131,7 +169,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( @@ -146,7 +185,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 11ced4ef2..56f6807e3 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,8 @@ import 'package:ndk/ndk.dart'; 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 @@ -58,6 +60,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 +78,7 @@ class NWalletCard extends StatefulWidget { this.cashuIcon, this.nwcIcon, this.lnurlIcon, + this.bolt12Icon, }); @override @@ -84,6 +90,9 @@ class _NWalletCardState extends State List? _customGradientColors; GetBudgetResponse? _budgetResponse; bool _isFetchingBudget = false; + bool _isRefreshingBalance = false; + bool _isWalletAvailable = true; + int _connectionCheckGeneration = 0; @override NdkFlutter get ndkFlutter => widget.ndkFlutter; @@ -102,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() { @@ -167,6 +198,10 @@ class _NWalletCardState extends State _budgetResponse = budget; }); } + } catch (_) { + if (mounted) { + setState(() => _isWalletAvailable = false); + } } finally { _isFetchingBudget = false; } @@ -201,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(); } @@ -247,14 +287,19 @@ 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 bool isLnBits = widget.wallet is LnBitsWallet; final nwcPermissions = isNwc ? _nwcPermissions(widget.wallet as NwcWallet) : const {}; 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 bool isWalletUnavailable = !_isWalletAvailable; final String walletName; if (isCashu) { @@ -267,6 +312,10 @@ 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 if (isLnBits) { + walletName = (widget.wallet as LnBitsWallet).name; } else { walletName = l10n.unknownWalletType; } @@ -284,6 +333,19 @@ 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 if (isLnBits) { + subtitle = (widget.wallet as LnBitsWallet).lnbitsUrl.replaceFirst( + RegExp(r'^https?://'), + '', + ); } else { subtitle = ''; } @@ -303,10 +365,19 @@ class _NWalletCardState extends State .toColor(); gradientColors = [color, lighterColor]; } else { - gradientColors = _getDefaultGradientColors(isCashu, isNwc, isLnurl); + gradientColors = _getDefaultGradientColors( + isCashu, + isNwc, + isLnurl, + isBolt12, + isLnBits, + ); } } - 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; @@ -324,6 +395,14 @@ 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 if (isLnBits) { + iconConfig = const WalletIconConfig(); + defaultAssetName = null; + fallbackIcon = Icons.bolt; } else { iconConfig = const WalletIconConfig(); defaultAssetName = 'wallet.png'; @@ -333,7 +412,19 @@ 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, + ) + : isNwc + ? NNwcWalletIcon( + wallet: widget.wallet as NwcWallet, + size: iconConfig.iconSize, + ) + : isLnBits + ? NLnBitsIcon(size: iconConfig.iconSize) + : defaultAssetName != null ? Image.asset( 'assets/images/$defaultAssetName', package: 'ndk_flutter', @@ -356,7 +447,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', @@ -379,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), @@ -389,7 +517,7 @@ class _NWalletCardState extends State gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: gradientColors, + colors: effectiveGradientColors, ), boxShadow: [ BoxShadow( @@ -404,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( @@ -414,7 +542,52 @@ class _NWalletCardState extends State Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - mainIcon, + effectiveMainIcon, + 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), @@ -462,22 +635,19 @@ 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 + isWalletUnavailable + ? _buildUnavailableInfo(context) + : isLnurl ? _buildLnurlInfo( context, widget.wallet as LnurlWallet, ) + : isBolt12 + ? _buildBolt12Info( + context, + widget.wallet as Bolt12Wallet, + ) : (canShowNwcBalance ? _buildBalance(context) : const SizedBox.shrink()), @@ -511,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), @@ -522,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), @@ -534,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), @@ -571,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( @@ -595,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( @@ -805,6 +978,8 @@ class _NWalletCardState extends State bool isCashu, bool isNwc, bool isLnurl, + bool isBolt12, + bool isLnBits, ) { if (isCashu) { return [const Color(0xFF7F38CA), const Color(0xFF9B5AD8)]; @@ -815,6 +990,10 @@ 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 if (isLnBits) { + return [const Color(0xFF21172F), const Color(0xFF3B2853)]; } else { return [Colors.grey[700]!, Colors.grey[400]!]; } @@ -1003,6 +1182,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) { @@ -1018,6 +1198,25 @@ 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, + 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'); } @@ -1042,11 +1241,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, @@ -1068,6 +1262,116 @@ 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 _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( @@ -1109,12 +1413,74 @@ 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 { + final generation = ++_connectionCheckGeneration; + final walletId = widget.wallet.id; + final wasUnavailable = !_isWalletAvailable; + setState(() => _isRefreshingBalance = true); + try { + 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 && + widget.wallet.id == walletId && + generation == _connectionCheckGeneration) { + setState(() => _isWalletAvailable = false); + 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 1478077fb..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 @@ -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, }); @@ -119,6 +123,7 @@ class _NWalletCardListState extends State { name: wallet.name, supportedUnits: wallet.supportedUnits, nwcUrl: wallet.nwcUrl, + providerId: wallet.providerId, metadata: metadata, ); } @@ -135,6 +140,25 @@ 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, + 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_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 c080b4e50..33964d08c 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_wallets.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_wallets.dart @@ -67,6 +67,20 @@ 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; + + /// Optional host-provided scanner accepting every supported wallet input. + final WalletInputScanner? walletInputScanner; + + /// Wallet apps or web services offering assisted NWC authorization. + /// Null enables Alby Cloud and Coinos presets; an empty list disables them. + final List? nwcConnectionOptions; + + /// 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; @@ -76,6 +90,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 +115,14 @@ class NWallets extends StatefulWidget { this.albyGoConnectConfig = kDefaultAlbyGoConnectConfig, this.nwcWalletAuthCoordinator, this.nwcUriScanner, + this.bolt12InputScanner, + this.walletInputScanner, + this.nwcConnectionOptions, + this.walletQrScannerBuilder, this.cashuIcon, this.nwcIcon, this.lnurlIcon, + this.bolt12Icon, }); @override @@ -116,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 { @@ -126,7 +168,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) { @@ -135,7 +192,6 @@ class NWalletsState extends State { }); widget.onWalletSelected?.call(connectedWalletId); } - return handled; } @override @@ -181,6 +237,7 @@ class NWalletsState extends State { cashuIcon: widget.cashuIcon, nwcIcon: widget.nwcIcon, lnurlIcon: widget.lnurlIcon, + bolt12Icon: widget.bolt12Icon, ), ), ], @@ -216,6 +273,7 @@ class NWalletsState extends State { cashuIcon: widget.cashuIcon, nwcIcon: widget.nwcIcon, lnurlIcon: widget.lnurlIcon, + bolt12Icon: widget.bolt12Icon, ), ), if (showActionsSection) ...[ @@ -268,7 +326,11 @@ class NWalletsState extends State { widget.ndkFlutter, albyGoConnectConfig: widget.albyGoConnectConfig, nwcWalletAuthCoordinator: _nwcWalletAuthCoordinator, + walletInputScanner: widget.walletInputScanner, + walletQrScannerBuilder: widget.walletQrScannerBuilder, + nwcConnectionOptions: widget.nwcConnectionOptions, 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..b0fba7741 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'; @@ -6,6 +9,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 +172,85 @@ 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 CashuWallet) { + _showReceiveDialog(context, wallet); + } else if (wallet.supportsBolt11InvoiceReceive) { _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, @@ -216,6 +293,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( @@ -278,7 +369,7 @@ mixin WalletActionDialogsMixin on State { generating ? l10n.generatingBackup : l10n.backup, ), ) - else + else ...[ TextButton( onPressed: () async { await Clipboard.setData(ClipboardData(text: backupJson!)); @@ -286,6 +377,12 @@ mixin WalletActionDialogsMixin on State { }, child: Text(l10n.copyBackup), ), + TextButton.icon( + onPressed: saveToFile, + icon: const Icon(Icons.save_alt), + label: Text(l10n.saveBackupToFile), + ), + ], ], ); }, @@ -308,6 +405,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( @@ -320,6 +436,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 @@ -376,15 +497,29 @@ 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) => + destination.id != wallet.id && + 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 +530,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 +538,264 @@ 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) ...[ + ] else if (wallet.supportsBolt11InvoicePay) ...[ 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( + leading: const Icon(Icons.swap_horiz), + title: Text(l10n.sendToWallet), + subtitle: Text( + destinations.isEmpty + ? l10n.noCompatibleReceivingWallets + : l10n.sendToWalletDescription, + ), + onTap: () => + 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: + 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, + 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) { @@ -620,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( @@ -764,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/ndk_flutter/lib/widgets/widgets.dart b/packages/ndk_flutter/lib/widgets/widgets.dart index 705d9a72b..0c0a9b0a9 100644 --- a/packages/ndk_flutter/lib/widgets/widgets.dart +++ b/packages/ndk_flutter/lib/widgets/widgets.dart @@ -14,3 +14,5 @@ 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'; +export 'wallets/n_wallet_input_dialog.dart'; diff --git a/packages/ndk_flutter/pubspec.yaml b/packages/ndk_flutter/pubspec.yaml index bfa6457af..ebc4b881a 100644 --- a/packages/ndk_flutter/pubspec.yaml +++ b/packages/ndk_flutter/pubspec.yaml @@ -29,6 +29,8 @@ 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 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..21caad154 --- /dev/null +++ b/packages/ndk_flutter/test/wallet_input_classifier_test.dart @@ -0,0 +1,281 @@ +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'; + +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', + 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', () { + 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, + state: '0123456789abcdef0123456789abcdef', + ); + + 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'); + 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', () { + 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/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/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/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/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/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..d2787c5c6 100644 --- a/packages/sample-app/lib/nwc_qr_scanner.dart +++ b/packages/sample-app/lib/nwc_qr_scanner.dart @@ -1,218 +1,34 @@ 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/ndk.dart'; -import 'package:ndk_flutter/l10n/app_localizations.dart' as ndk_l10n; -Future scanNwcUri(BuildContext context) { - return showDialog( - context: context, - builder: (context) => const _NwcQrScannerDialog(), - ); -} - -class _NwcQrScannerDialog extends StatefulWidget { - const _NwcQrScannerDialog(); - - @override - State<_NwcQrScannerDialog> createState() => _NwcQrScannerDialogState(); -} - -class _NwcQrScannerDialogState extends State<_NwcQrScannerDialog> { - MobileScannerController? _scannerController; - bool _hasScanned = false; - String? _errorMessage; +import 'linux_qr_scanner.dart'; - bool get _hasCamera => - !kIsWeb && - (defaultTargetPlatform == TargetPlatform.android || - defaultTargetPlatform == TargetPlatform.iOS); - - @override - void initState() { - super.initState(); - if (_hasCamera) { - _scannerController = MobileScannerController( - detectionSpeed: DetectionSpeed.normal, - facing: CameraFacing.back, - ); - } - } - - @override - void dispose() { - _scannerController?.dispose(); - super.dispose(); +/// Camera adapter only. Wallet input UI and navigation belong to ndk_flutter. +Widget buildWalletQrScanner( + BuildContext context, + ValueChanged onScan, + ValueChanged onError, +) { + if (kIsWeb || defaultTargetPlatform == TargetPlatform.linux) { + return FullFrameQrScanner(onScan: onScan, onError: onError); } - - 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; + return MobileScanner( + onDetect: (capture) { + for (final barcode in capture.barcodes) { + final value = barcode.rawValue?.trim(); + if (value != null && value.isNotEmpty) { + onScan(value); + break; + } } - - setState(() { - _errorMessage = l10n.invalidNwcQrCode; + }, + errorBuilder: (context, error) { + // Report after build so the parent can display its shared error UI. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) onError(error); }); - } - } - - 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; - if (text != null && text.startsWith(Nwc.kNWCProtocolPrefix)) { - Navigator.of(context).pop(text); - return; - } - - setState(() { - _errorMessage = l10n.invalidNwcUri; - }); - } - - @override - Widget build(BuildContext context) { - final l10n = ndk_l10n.AppLocalizations.of(context)!; - final hasCamera = _hasCamera; - - 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: 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: [ - if (hasCamera) ...[ - Text( - l10n.scanNwcInstructions, - style: const TextStyle(color: Colors.white70), - textAlign: TextAlign.center, - ), - 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), - ), - ), - ], - ), - ), - ], - ), - ), - ); - } - - 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, - ), - ), - ); - } + return const SizedBox.shrink(); + }, + ); } diff --git a/packages/sample-app/lib/wallets.dart b/packages/sample-app/lib/wallets.dart index 36e5717d9..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'; @@ -23,6 +24,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((_) { @@ -33,6 +35,9 @@ class WalletsPageState extends State with WidgetsBindingObserver { @override void dispose() { + if (activeWalletProtocolHandler == onProtocolUrlReceived) { + activeWalletProtocolHandler = null; + } WidgetsBinding.instance.removeObserver(this); super.dispose(); } @@ -46,13 +51,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(); + } }); } @@ -72,7 +77,12 @@ class WalletsPageState extends State with WidgetsBindingObserver { body: NWallets( key: _walletsKey, ndkFlutter: ndkFlutter, - nwcUriScanner: scanNwcUri, + walletQrScannerBuilder: kIsWeb || + defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.linux + ? buildWalletQrScanner + : null, ), ); } 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/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 93eb9f82c..938a8faa2 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: @@ -323,6 +363,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -333,6 +381,22 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_webrtc: + dependency: "direct main" + 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: @@ -390,13 +454,77 @@ packages: source: hosted version: "2.9.2" image: - dependency: transitive + dependency: "direct main" description: name: image sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce 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: @@ -453,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: @@ -549,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: @@ -579,21 +723,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: @@ -642,6 +786,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 +1255,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: @@ -1175,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: @@ -1224,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 ba671b7ae..e57d14f29 100644 --- a/packages/sample-app/pubspec.yaml +++ b/packages/sample-app/pubspec.yaml @@ -53,6 +53,9 @@ dependencies: http: ^1.2.0 qr_flutter: ^4.1.0 mobile_scanner: ^7.2.1 + flutter_webrtc_zxing: ^0.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); + }); +} 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 )